Standard mode queue
A standard-mode queue delivers each job to your webhook; your handler does the work and returns 2xx to mark the job done.
This is the simplest way to run jobs on SimpleQ. Your worker verifies the webhook signature, does the work, and replies with a status code — 2xx if it succeeded, or a 5xx to have SimpleQ retry. Drop in your own work and you have the integration.
Reach for standard mode over ack mode when each job finishes well inside the 15-second delivery window and the HTTP response is the only signal you need: a 2xx completes the job, a 5xx fails the attempt and SimpleQ retries it with backoff, and relaying a downstream 429, 503, or 529 passes the backpressure through so SimpleQ redelivers later without burning an attempt. Every tab below implements that same contract — Node/Express, Python/FastAPI, Go, Java/Spring Boot, PHP, and C#/.NET — each verifying the x-simpleq-signature HMAC over the raw request body before touching the payload. If the work can outlast the window, use an ack-mode queue instead — the generic ack worker shows that flow: respond 200 first, then report the outcome out of band.
Prerequisite
This example requires a queue created with mode: 'standard' — the default, so only name and webhookUrl are required.
The worker
Pick the tab for your language — each tab is a complete, single-file worker you can copy and run. The handler runs your work inline and replies with a status code. SimpleQ waits up to 15 seconds for the response.
// npm install @simpleq/sdk express
import express from 'express';
import { simpleqWebhookHandler } from '@simpleq/sdk/express';
const app = express();
// Replace this with your real work. It MUST finish well under the 15s ceiling.
// Return on success; throw to fail the attempt (→ 500, retried with backoff).
async function doTheWork(payload) {
// your code here
}
// simpleqWebhookHandler verifies x-simpleq-signature against the raw body (401 on
// mismatch — see https://docs.simpleq.io/concepts/signature-verification),
// parses the envelope, and maps the handler outcome to a status code:
// resolve → 200 (job completed) · throw → 500 (retried with backoff, then DLQ).
app.post(
'/webhook',
simpleqWebhookHandler(process.env.SQ_SIGNING_SECRET, async (job) => {
await doTheWork(job.payload);
}),
);
app.listen(9000, () => {
console.log('standard worker on :9000');
});import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Header, Request, Response
SQ_SIGNING_SECRET = os.environ["SQ_SIGNING_SECRET"]
app = FastAPI()
# Verify the webhook came from SimpleQ.
# See https://docs.simpleq.io/concepts/signature-verification for the full breakdown.
def verify_signature(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
expected = "sha256=" + hmac.new(SQ_SIGNING_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
# Replace this with your real work. It MUST finish well under the 15s ceiling.
# Return on success; raise to fail the attempt (→ 500, retried with backoff).
async def do_the_work(payload: dict) -> None:
# your code here
pass
@app.post("/webhook")
async def webhook(request: Request, x_simpleq_signature: str | None = Header(default=None)):
# Read the raw body BEFORE parsing — the HMAC is over the exact bytes.
raw = await request.body()
if not verify_signature(raw, x_simpleq_signature):
return Response(status_code=401)
job = json.loads(raw)
try:
await do_the_work(job["payload"])
# A 2xx within 15s marks the job completed.
return Response(status_code=200)
except Exception:
# Failed attempt → retried with backoff up to maxAttempts, then the DLQ.
return Response(status_code=500)
# Run with: uvicorn worker:app --port 9000package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
)
var sqSigningSecret = os.Getenv("SQ_SIGNING_SECRET")
// A SimpleQ job as delivered to your webhook. Your data is under Payload.
type Job struct {
ID string `json:"id"`
Queue string `json:"queue"`
Payload json.RawMessage `json:"payload"`
Attempt int `json:"attempt"`
MaxAttempts int `json:"maxAttempts"`
CreatedAt string `json:"createdAt"`
}
// Verify the webhook came from SimpleQ.
// See https://docs.simpleq.io/concepts/signature-verification for the full breakdown.
func verifySignature(rawBody []byte, header string) bool {
if header == "" {
return false
}
mac := hmac.New(sha256.New, []byte(sqSigningSecret))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(header))
}
// Replace this with your real work. It MUST finish well under the 15s ceiling.
// Return nil on success; return an error to fail the attempt (→ 500, retried).
func doTheWork(payload json.RawMessage) error {
// your code here
return nil
}
func webhook(w http.ResponseWriter, r *http.Request) {
// Read the raw body BEFORE parsing — the HMAC is over the exact bytes.
raw, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !verifySignature(raw, r.Header.Get("x-simpleq-signature")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var job Job
if err := json.Unmarshal(raw, &job); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Run the work synchronously, then let the status code report the outcome.
// A 2xx within 15s marks the job completed.
if err := doTheWork(job.Payload); err != nil {
// Failed attempt → retried with backoff up to maxAttempts, then the DLQ.
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/webhook", webhook)
log.Println("standard worker on :9000")
log.Fatal(http.ListenAndServe(":9000", nil))
}// Spring Boot single-file standard-mode worker. Deps (Maven):
// org.springframework.boot:spring-boot-starter-web
// com.fasterxml.jackson.core:jackson-databind (bundled with starter-web)
// Java 17+.
package com.example.simpleq;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@SpringBootApplication
public class Worker {
public static void main(String[] args) {
// This worker example listens on port 9000 (set server.port=9000).
SpringApplication.run(Worker.class, args);
System.out.println("standard worker on :9000");
}
}
@RestController
class WebhookController {
static final String SQ_SIGNING_SECRET = System.getenv("SQ_SIGNING_SECRET");
static final ObjectMapper MAPPER = new ObjectMapper();
// Verify the webhook came from SimpleQ.
// See https://docs.simpleq.io/concepts/signature-verification for the full breakdown.
boolean verifySignature(byte[] rawBody, String header) {
if (header == null) return false;
String expected = "sha256=" + hmacHex(rawBody);
// Constant-time compare over the raw bytes.
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
header.getBytes(StandardCharsets.UTF_8));
}
static String hmacHex(byte[] rawBody) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SQ_SIGNING_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(rawBody);
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// Replace this with your real work. It MUST finish well under the 15s ceiling.
// Return on success; throw to fail the attempt (-> 500, retried with backoff).
void doTheWork(JsonNode payload) {
// your code here
}
@PostMapping(value = "/webhook", consumes = MediaType.ALL_VALUE)
ResponseEntity<Void> webhook(
@RequestBody(required = false) byte[] rawBody,
@RequestHeader(value = "x-simpleq-signature", required = false) String signature) {
// Read the raw body BEFORE parsing -- the HMAC is over the exact bytes.
byte[] raw = rawBody == null ? new byte[0] : rawBody;
if (!verifySignature(raw, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
JsonNode job;
try {
job = MAPPER.readTree(raw);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
// Run the work synchronously, then let the status code report the
// outcome. A 2xx within 15s marks the job completed.
try {
doTheWork(job.path("payload"));
return ResponseEntity.ok().build();
} catch (Exception err) {
// Failed attempt -> retried with backoff up to maxAttempts, then the DLQ.
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}<?php
// Standard-mode worker — plain PHP webhook handler (no framework).
// Map your web server so that POST /webhook routes to this file.
$SQ_SIGNING_SECRET = getenv('SQ_SIGNING_SECRET');
// Verify the webhook came from SimpleQ.
// See https://docs.simpleq.io/concepts/signature-verification for the full breakdown.
function verify_signature($raw_body, $header) {
global $SQ_SIGNING_SECRET;
if (!$header) return false;
$expected = 'sha256=' . hash_hmac('sha256', $raw_body, $SQ_SIGNING_SECRET);
// hash_equals is constant-time — never use == on a signature.
return hash_equals($expected, $header);
}
// Replace this with your real work. It MUST finish well under the 15s ceiling.
// Return on success; throw to fail the attempt (→ 500, retried with backoff).
function do_the_work($payload) {
// your code here
}
// Read the raw body BEFORE parsing — the HMAC is over the exact bytes.
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SIMPLEQ_SIGNATURE'] ?? null;
if (!verify_signature($raw, $header)) {
http_response_code(401);
exit;
}
$job = json_decode($raw, true);
// Run the work synchronously, then let the status code report the outcome.
// A 2xx within 15s marks the job completed.
try {
do_the_work($job['payload']);
http_response_code(200);
} catch (Throwable $err) {
// Failed attempt → retried with backoff up to maxAttempts, then the DLQ.
http_response_code(500);
}using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var SQ_SIGNING_SECRET = Environment.GetEnvironmentVariable("SQ_SIGNING_SECRET")!;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Verify the webhook came from SimpleQ.
// See https://docs.simpleq.io/concepts/signature-verification for the full breakdown.
bool VerifySignature(byte[] rawBody, string? header)
{
if (string.IsNullOrEmpty(header)) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(SQ_SIGNING_SECRET));
var digest = Convert.ToHexString(hmac.ComputeHash(rawBody)).ToLowerInvariant();
var expected = Encoding.UTF8.GetBytes("sha256=" + digest);
var actual = Encoding.UTF8.GetBytes(header);
// Constant-time compare; FixedTimeEquals also handles the length check.
return CryptographicOperations.FixedTimeEquals(expected, actual);
}
// Replace this with your real work. It MUST finish well under the 15s ceiling.
// Return on success; throw to fail the attempt (→ 500, retried with backoff).
async Task DoTheWork(JsonElement payload)
{
// your code here
await Task.CompletedTask;
}
app.MapPost("/webhook", async (HttpRequest request) =>
{
// Read the raw body BEFORE parsing — the HMAC is over the exact bytes.
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms);
var raw = ms.ToArray();
var header = request.Headers["x-simpleq-signature"].FirstOrDefault();
if (!VerifySignature(raw, header))
return Results.Unauthorized();
var job = JsonSerializer.Deserialize<JsonElement>(raw);
// Run the work synchronously, then let the status code report the outcome.
// A 2xx within 15s marks the job completed.
try
{
await DoTheWork(job.GetProperty("payload"));
return Results.Ok();
}
catch (Exception)
{
// Failed attempt → retried with backoff up to maxAttempts, then the DLQ.
return Results.StatusCode(500);
}
});
Console.WriteLine("standard worker on :9000");
app.Run("http://0.0.0.0:9000");Run it
npm init -y
npm i express
SQ_SIGNING_SECRET=... \
node worker.mjspip install fastapi uvicorn
SQ_SIGNING_SECRET=... \
uvicorn worker:app --port 9000SQ_SIGNING_SECRET=... \
go run worker.go# spring-boot-starter-web on the classpath; server.port=9000
SQ_SIGNING_SECRET=... \
./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=9000# A synchronous handler works on any SAPI. For a quick local test:
SQ_SIGNING_SECRET=... \
php -S 0.0.0.0:9000 worker.phpSQ_SIGNING_SECRET=... \
dotnet runSQ_SIGNING_SECRET— your queue's signing secret. See Signature verification for where to find it (dashboard reveal orPOST /v1/queuesresponse, depending on how the queue was created).
Your worker listens on port 9000, but SimpleQ delivers from the cloud and can't reach localhost. Open a tunnel and point the queue at it:
cloudflared tunnel --url http://localhost:9000Set the printed https://… URL (with /webhook appended) as your queue's webhookUrl. See Local development for the full flow and updating the URL on restart.
What each piece does
| Piece | Concept |
|---|---|
Verifying x-simpleq-signature over the raw body | Signature verification — prevents forged webhooks. |
Returning 2xx after the work completes | Job delivery — a 2xx within 15s marks the job completed. |
Returning 500 (or any other non-2xx, or no response in 15s) | Failed attempt → retried with backoff up to maxAttempts, then the DLQ if dlqEnabled: true. |
Relaying backpressure
If your work calls a downstream that rate-limits you (HTTP 429 / 503 / 529), return that same status with a Retry-After header instead of 500. SimpleQ holds the job and redelivers it after the delay without burning an attempt — see Backpressure for the pattern.
Test it with a publish
Once the worker is running, publish a job to trigger a delivery:
curl -X POST "$SQ_API_URL/v1/queues/<your-queue>/jobs" \
-H "Authorization: Bearer $SIMPLEQ_API_KEY" \
-H "content-type: application/json" \
-d '{ "payload": { "hello": "world" }, "idempotencyKey": "test-1" }'You should see a 200 on the worker side and the job transition to completed (assuming doTheWork succeeded). Watch the queue detail page in the dashboard to see the state change in real time.