Skip to content

Push subscriptions

Have Queues push batches of messages to your HTTPS endpoint, verify their signatures, and report partial failures.

Updated View as Markdown

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.com or ezgh.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 3xx answer 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:

  1. Split the header on , into t= (Unix seconds) and one or more v1= (hex).
  2. Refuse the request if t is more than 300 seconds from now, either way.
  3. Decode each signing secret: the text after whsec_ is the base64url (unpadded) encoding of the 32-byte HMAC key.
  4. Compute HMAC-SHA256(key, t + "." + body) over the raw body bytes, before parsing them. The request is genuine if any v1 equals it for any of your secrets. Compare in constant time.
server.mjsjs
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)
deliveries.gogo
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})
	}
}
app.pypython
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.
  • 429 or 503 with Retry-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 maxReceiveCount attempts. Without one, it’s retried until the queue’s retention ends.
  • Slowing down: after a 429, a 503 or a timeout, Queues halves the subscription’s deliveries in flight, then adds one back per successful delivery, up to maxConcurrency.

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

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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close