A subscription is a queue’s managed consumer: Queues receives the queue’s messages for you and
POSTs them in batches to your HTTPS endpoint. Your endpoint answers 2xx to acknowledge a batch,
or lists the messages it couldn’t handle. A queue has at most one subscription.
Create a subscription
curl -sS -X POST "https://queues-api.ezghcloud.com/v1/organizations/$ORG_ID/projects/$PROJECT_ID/subscriptions" \
-H "Authorization: Bearer $EZGH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"queueId": "01999f3c-7a1e-7b2a-9c4d-5e6f7a8b9c0d",
"target": { "kind": "https", "url": "https://hooks.example.com/queues/orders", "timeoutSeconds": 30 },
"batchSize": 10,
"maxConcurrency": 10
}'| Field | Default | Meaning |
|---|---|---|
queueId |
The queue to consume. Fixed | |
target.url |
Your endpoint (rules below) | |
target.timeoutSeconds |
30 | How long a delivery may take, 1 to 60 seconds |
batchSize |
10 | Messages per delivery, at most; 1 to 10 |
maxConcurrency |
10 | Deliveries in flight at once, 1 to 100 |
state |
active |
paused creates it without delivering |
CreateSubscription is checked as
queues.subscriptions.create on the queue, and answers 409 subscription_exists if the queue
already has one. The answer carries the signing secret (signingSecret, whsec_…) once:
store it where your endpoint can read it. A repeated create with the same Idempotency-Key
answers signingSecret: null; if you lost the secret, rotate it.
Deliveries start at once, and pull consumers can still receive from the queue: they and the subscription compete for messages.
With the CLI: ezgh queues subscriptions create orders --url https://hooks.example.com/q
prints the signing secret once; ezgh queues subscriptions also lists, pauses, resumes, changes,
deletes and rotates the secret of subscriptions.
Endpoints Queues calls
https://on port 443, with a publicly trusted certificate. No user name, password or fragment in the URL, at most 2,048 characters.- A public DNS name: not an IP address, a single label,
localhost, or a name under.local,.internal,.test,.svc,.localhost,ezghcloud.comorezgh.app. - At every delivery, the name must resolve to public addresses only. Private, loopback, link-local and shared addresses are refused.
- Redirects aren’t followed: a
3xxanswer is a failed delivery.
The request
Each delivery is a POST with Content-Type: application/json, these headers:
| Header | Value |
|---|---|
EZGH-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256>, one v1 per active signing secret. See Verify the signature |
EZGH-Delivery-Id |
This delivery’s ID, also in the body as deliveryId |
and a body like this:
{
"subscription": "qsb_7m2k9x0q4r1s",
"queue": "que_4k2m9x0a7qpe",
"deliveryId": "01999f41-6c3b-7a2d-9e1f-0a1b2c3d4e5f",
"messages": [
{
"messageId": "01999f3d-02b4-7c1d-8e2f-3a4b5c6d7e8f",
"body": "{\"orderId\": \"o-123\"}",
"attributes": { "source": "checkout" },
"sentAt": "2026-10-13T17:02:40Z",
"receiveCount": 1
}
]
}messages holds 1 to batchSize messages. On a FIFO queue each message also has groupId and
seq, and a batch can hold several groups, each in order. receiveCount counts delivery attempts
and pull receives together.
Verify the signature
Check EZGH-Signature on every request before you trust its body. It’s the scheme Workflows push
apps use:
- Split the header on
,intot=(Unix seconds) and one or morev1=(hex). - Refuse the request if
tis more than 300 seconds from now, either way. - Decode each signing secret: the text after
whsec_is the base64url (unpadded) encoding of the 32-byte HMAC key. - Compute
HMAC-SHA256(key, t + "." + body)over the raw body bytes, before parsing them. The request is genuine if anyv1equals it for any of your secrets. Compare in constant time.
import { createServer } from "node:http"
import { createHmac, timingSafeEqual } from "node:crypto"
// During a rotation, list the new secret and the previous one.
const secrets = [process.env.EZGH_QUEUES_SIGNING_SECRET]
function verifySignature(header, body, secrets, now = Date.now()) {
let t = -1
const signatures = []
for (const part of (header ?? "").split(",")) {
const [key, value = ""] = part.trim().split("=")
if (key === "t" && /^\d+$/.test(value)) t = Number(value)
if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) signatures.push(Buffer.from(value, "hex"))
}
if (t < 0 || signatures.length === 0) return false
if (Math.abs(now / 1000 - t) > 300) return false // more than 5 minutes old or ahead
return secrets.some((secret) => {
const key = Buffer.from(secret.slice("whsec_".length), "base64url")
const expected = createHmac("sha256", key).update(`${t}.`).update(body).digest()
return signatures.some((signature) => timingSafeEqual(expected, signature))
})
}
createServer(async (req, res) => {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const body = Buffer.concat(chunks)
if (!verifySignature(req.headers["ezgh-signature"], body, secrets)) {
res.writeHead(401).end()
return
}
const delivery = JSON.parse(body.toString("utf8"))
const failed = []
for (const message of delivery.messages) {
try {
await handle(message) // your code
} catch {
failed.push(message.messageId)
}
}
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ failed }))
}).listen(8080)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
)
func verifySignature(header string, body []byte, secrets []string, now time.Time) bool {
t := int64(-1)
var signatures [][]byte
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
switch key {
case "t":
if n, err := strconv.ParseUint(value, 10, 63); err == nil {
t = int64(n)
}
case "v1":
if b, err := hex.DecodeString(value); err == nil && len(b) == sha256.Size {
signatures = append(signatures, b)
}
}
}
if t < 0 || len(signatures) == 0 {
return false
}
if age := now.Unix() - t; age > 300 || age < -300 { // more than 5 minutes old or ahead
return false
}
for _, secret := range secrets {
key, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
continue
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, signature := range signatures {
if hmac.Equal(expected, signature) {
return true
}
}
}
return false
}
type message struct {
MessageID string `json:"messageId"`
Body string `json:"body"`
Attributes map[string]string `json:"attributes"`
}
// deliveries serves a subscription's endpoint. During a rotation, pass the new secret and the
// previous one.
func deliveries(secrets []string, handle func(*http.Request, message) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 16<<20))
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
if !verifySignature(r.Header.Get("EZGH-Signature"), body, secrets, time.Now()) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var delivery struct {
Messages []message `json:"messages"`
}
if err := json.Unmarshal(body, &delivery); err != nil {
http.Error(w, "bad JSON", http.StatusBadRequest)
return
}
failed := []string{}
for _, m := range delivery.Messages {
if err := handle(r, m); err != nil {
failed = append(failed, m.MessageID)
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string][]string{"failed": failed})
}
}import base64
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, Request, Response
def verify_signature(header, body, secrets, now=None):
t, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isascii() and value.isdigit():
t = int(value)
elif key == "v1" and len(value) == 64 and value.isascii():
signatures.append(value)
if t is None or not signatures:
return False
if abs((time.time() if now is None else now) - t) > 300: # more than 5 minutes old or ahead
return False
for secret in secrets:
encoded = secret.removeprefix("whsec_")
key = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
expected = hmac.new(key, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, s) for s in signatures):
return True
return False
app = FastAPI()
# During a rotation, list the new secret and the previous one.
SECRETS = [os.environ["EZGH_QUEUES_SIGNING_SECRET"]]
@app.post("/queues/orders")
async def deliveries(request: Request):
body = await request.body()
if not verify_signature(request.headers.get("EZGH-Signature"), body, SECRETS):
return Response(status_code=401)
failed = []
for message in json.loads(body)["messages"]:
try:
await handle(message) # your code
except Exception:
failed.append(message["messageId"])
return {"failed": failed}Answer the delivery
Answer within the subscription’s timeoutSeconds:
| Your answer | What happens |
|---|---|
2xx |
The whole batch is acknowledged, whatever the body says, unless it’s the JSON below |
2xx with {"failed": ["<messageId>", …]} |
Every message except those listed is acknowledged. The listed ones are retried. IDs that weren’t in the batch are ignored |
Any other status, 3xx included |
The whole batch is retried |
No answer within timeoutSeconds, or a connection or TLS error |
The whole batch is retried |
A 2xx body that isn’t JSON, or isn’t that shape, acknowledges the whole batch: list failures
only in failed. On a FIFO queue a failed message also fails the later messages of its group in
the same batch, so they’re retried after it, in order (FIFO queues).
While a delivery is open, its messages are hidden for the longer of the queue’s visibility timeout
and timeoutSeconds plus 30 seconds, so a slow endpoint doesn’t get a second copy while it’s
still working. Deliveries are at least once all the same: an endpoint that handled a message but
didn’t answer in time gets it again. Use messageId to recognize repeats.
Retries
- A failed message becomes visible again after
2^(receiveCount − 1)seconds (1, 2, 4, 8, 16 …), with ±20 % jitter, and at most the queue’s visibility timeout. It’s then delivered again, or received by a pull consumer. 429or503withRetry-After(in seconds): the batch waits that long, at most the queue’s visibility timeout.- Each attempt counts as a receive. With a redrive policy, a
message moves to the dead-letter queue after
maxReceiveCountattempts. Without one, it’s retried until the queue’s retention ends. - Slowing down: after a
429, a503or a timeout, Queues halves the subscription’s deliveries in flight, then adds one back per successful delivery, up tomaxConcurrency.
Status
GetSubscription shows how deliveries are going:
| Field | Meaning |
|---|---|
status.health |
healthy, or failing after a failed delivery until one succeeds |
status.lastError |
The last failure: at, class (http, timeout, connection, refused or tls), the endpoint’s status when it answered, and a message of at most 200 characters. refused is an address or name the endpoint rules don’t allow |
status.lastSuccessAt |
The last successful delivery |
Individual delivery attempts aren’t kept.
Pause, resume, change and delete
- PauseSubscription stops deliveries. Messages wait in
the queue, or go to pull consumers. ResumeSubscription
starts them again. Both need
queues.subscriptions.pause. - UpdateSubscription changes
target,batchSizeormaxConcurrency. The queue is fixed. - DeleteSubscription stops deliveries; the queue and its messages stay.
Rotate the signing secret
RotateSubscriptionSecret returns a new secret,
once, and needs queues.subscriptions.update. For 24 hours, deliveries are signed with both the
new and the previous secret (two v1 values), then with the new one only. Deploy the new secret
alongside the old, then remove the old one.