# Queuey — complete documentation The async queue for your API — in front of it for what comes in, behind it for what goes out. Webhooks and events are acknowledged once stored and delivered at the pace you set, with retries, ordering, replay and recovery built in. Every public documentation page, in full. The index lives at /llms.txt; individual pages are at /docs/md/{slug}, and the browsable docs at https://queuey.ai/docs. --- # Queuey docs — reliable webhook delivery & event infrastructure The async queue for your API: Queuey stores webhooks and events, answers at once, and delivers them at your pace with retries, DLQ, replay and signing. Source: https://queuey.ai/docs --- # Queuey docs — reliable webhook delivery Queuey is **the async queue for your API** — in front of it for what comes in, behind it for what goes out. You POST webhooks and events to a queue over HTTP; Queuey verifies and stores them, answers at once, and delivers them to your target at the pace you set — with retries by failure type, a dead-letter queue, idempotency, HMAC & OAuth2 signing, and per-key ordering — then gives you the tooling to diagnose and replay anything that fails. [Start the 5-minute quickstart](https://queuey.ai/docs/quickstart) ## What Queuey gives you Automatic retries & dead-letter queue Failed webhook deliveries retry with exponential backoff and jitter, classified by failure type, then land in a DLQ you can inspect and replay. Idempotency & safe-by-default delivery Inbound dedup on the Idempotency-Key, outbound key threading, and Hold-on-timeout so a non-idempotent receiver never gets duplicate side effects. HMAC & OAuth2 signing Sign outbound requests with HMAC, or authenticate to the target with OAuth2 client credentials — including private_key_jwt with a P12 certificate. Per-key ordering & lanes Process events for each key in strict order, in independent lanes, without head-of-line blocking across keys. Secure ingress API-key or HMAC signed-request auth, a source-IP allowlist, idempotency, and loop prevention on every event you POST. Operational observability Diagnose failing targets, see incident and after-action reports, and replay events — Queuey is built to operate delivery, not just attempt it. ## Where to go next - [Quickstart](https://queuey.ai/docs/quickstart) — send your first event with curl, TypeScript, or C#. - [Core concepts](https://queuey.ai/docs/concepts) — tenants, queues, the policy cascade, and the event lifecycle. - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries, idempotency, Hold-on-timeout, and the dead-letter queue. - [OAuth2 & private_key_jwt](https://queuey.ai/docs/how-to/oauth2-private-key-jwt) — certificate-based delivery auth for regulated targets. - [API reference](https://app.queuey.ai/console/developer) — ingress, auth, queues, events, and metrics endpoints (in the console). # Quickstart — send your first webhook event with Queuey Send your first event to Queuey in five minutes: get an ingress URL, POST an event with curl, TypeScript or C#, and watch it deliver with automatic retries. Source: https://queuey.ai/docs/quickstart --- Getting started # Quickstart Send your first event to Queuey in about five minutes — from zero to a delivered webhook with automatic retries. Examples in curl, TypeScript, and C#. 1. Get an ingress URL and an API key Create a Queuey account, then issue an API key for an API client (your integration's identity) in the console. Copy the key when it's shown — it's displayed once. 2. Identify your workspace and queue Your ingress URL is /events/{tenantPublicId}/{queueName}. The workspace id (the API calls workspaces tenants) comes from the license picker; the queue name from its detail page. Check the queue's mode there too: in Log only, Queuey stores and shows events but delivers nothing. To deliver, give the queue a target and set it to Deliver. 3. POST your first event Pick a language below and send the request. Queuey replies 202 Accepted with an eventId the instant the event lands — delivery happens asynchronously after that. 4. Watch it deliver In the console, a queue in Log only shows the event as Logged. In Deliver, it moves Received → InProgress → Delivered as the worker forwards it to your target. When a delivery fails, Queuey acts on what the receiver answered: a receiver that is down is probed until it answers, and delivery resumes on its own; an event the receiver refuses goes to the dead-letter queue; and when a person must act, as with a rejected credential, the queue stops until someone fixes the cause and runs Verify & resume. ## Send the event ```bash curl -X POST "https://ingress.queuey.ai/events/ten_yourTenant/customer-events" \ -H "X-Api-Key: qak_8fK2mNpQ.aB3dE7gH9jK1mN4pR6tV8wX0yZ" \ -H "Content-Type: application/json" \ -d '{ "eventType": "customer.created", "payload": { "customerId": "10042", "email": "alice@example.com" } }' ``` ```typescript const res = await fetch( `${process.env.QUEUEY_INGRESS_URL}/events/${tenantPublicId}/customer-events`, { method: "POST", headers: { "X-Api-Key": process.env.QUEUEY_API_KEY!, "Content-Type": "application/json", }, body: JSON.stringify({ eventType: "customer.created", payload: { customerId: "10042", email: "alice@example.com" }, }), }, ); if (res.status !== 202) throw new Error(`Ingress rejected: ${await res.text()}`); const ack = await res.json(); console.log("Accepted:", ack.eventId); ``` ```csharp using var http = new HttpClient { BaseAddress = new Uri(ingressHost) }; http.DefaultRequestHeaders.Add("X-Api-Key", apiKey); var res = await http.PostAsJsonAsync( $"events/{tenantPublicId}/customer-events", new { eventType = "customer.created", payload = new { customerId = "10042", email = "alice@example.com" }, }); res.EnsureSuccessStatusCode(); // 202 Accepted ``` ## What you get back A `202 Accepted` with the `eventId`. Delivery is asynchronous — track it by `eventId` in the console. Response ```http HTTP/1.1 202 Accepted Content-Type: application/json { "queuePublicId": "que_Rp2yPELnp4sr", "eventId": "evt_nd9qgy1sjPIW", "receivedAtUtc": "2026-06-25T14:38:36.69Z", "mode": "Deliver", "replayed": false } ``` Next steps - Publishing from an **unreliable network** (factories, kiosks, vehicles, on-prem)? Use [Queuey Edge](https://queuey.ai/docs/how-to/edge) — `PublishAsync` commits durably on the local machine and Queuey handles transfer, retries and backlog draining. - Going to production? Replace the API key with **HMAC signed-request** auth, and verify the signature on your receiver before flipping the queue's auth mode. - Integrating a regulated or enterprise target? Use **OAuth2 client credentials** — including `private_key_jwt` with a P12 certificate. - Explore the full [API reference](https://app.queuey.ai/console/developer) or head back to the [docs overview](https://queuey.ai/docs). # Let your AI set up Queuey — one prompt, from repository to delivered event Paste one prompt into Claude Code, Cursor or any coding agent. It reads your repository, works out whether you should publish with the SDK or with Queuey Edge, and sets it up — stopping at the one step only you can do. Source: https://queuey.ai/docs/agent-setup --- Getting started # Let your AI set it up Paste one prompt into Claude Code, Cursor, or whichever coding agent you use. It reads your repository, configures delivery to your receiver and verifies an event through your application. You approve the sign-in and any production changes that require your approval. ## The prompt Copy this into your agent, in the repository you want to connect. Paste into your agent ```text Set up Queuey (https://queuey.ai) in this repository. The defaults below do the whole job; ask me only where a step says so. An engineer steers with queuey plan, queuey.deploy.json and the flags: when you are done, tell me in a few lines what you set up and where those are. Where to read, as plain text. Read it when you need exact syntax; do not guess at it: - https://queuey.ai/llms.txt is the index, https://queuey.ai/llms-full.txt every page at once (if your fetch cuts it short, read single pages instead). - https://queuey.ai/docs/md/{slug} is one page as Markdown, for example https://queuey.ai/docs/md/quickstart. - https://queuey.ai/.well-known/agent-skills/index.json lists the agent skills. - queuey --help, queuey --help, and queuey advise. A sandbox that cannot reach localhost can still read queuey.ai. If I give you addresses for a local or self-hosted Queuey (API, ingress, console, docs), they win over the queuey.ai addresses in this text. 1. First, ask me once: have I decided where Queuey goes — which part sends, which part receives, or which services? If I have, follow that. If I say "see for yourself", run queuey advise --json (step 3), tell me which files it read (filesRead), and propose a place before you write any code. 2. Install the CLI if it is not there. With .NET 8 or later: dotnet tool install -g Queuey.Cli --prerelease The tool lands in ~/.dotnet/tools. If queuey is not found afterwards, put that folder on PATH as $HOME/.dotnet/tools, for example in ~/.zshrc: export PATH="$PATH:$HOME/.dotnet/tools" Not as "~/.dotnet/tools": a ~ inside quotes is not expanded, and PATH then names a folder that does not exist. No .NET? Every release ships the CLI as one self-contained file, with no runtime needed, for osx-arm64, osx-x64, linux-x64, linux-arm64 and win-x64 (.zip). On macOS or Linux, with this machine's platform in place of osx-arm64: curl -fsSL https://github.com/Queuey-AI/queuey-client/releases/latest/download/queuey-osx-arm64.tar.gz | tar -xz Then move ./queuey onto PATH, or call it as ./queuey. A 404 means there is no build for this machine: install .NET 8 and use the command above. Check it with queuey --version. This setup requires Queuey.Cli, Queuey.Client and Queuey.Edge ≥ 0.1.0-preview.9 (newer than 0.1.0-preview.8). In a .NET project, add the package for the job, with --prerelease: - Queuey.Client, to publish from a service that is online: dotnet add package Queuey.Client --prerelease - Queuey.Edge, to publish from a machine with local disk that survives a restart, where the network can drop: dotnet add package Queuey.Edge --prerelease - Queuey.Client.Waas, only to declare event streams for partners: dotnet add package Queuey.Client.Waas --prerelease Anywhere else there is no package to add: one signed POST to the ingress, as in https://queuey.ai/docs/md/quickstart and https://queuey.ai/docs/md/how-to/signed-requests. Only from server-side code — the key must never end up in a browser bundle. You use the CLI. Queuey's MCP server, https://api.queuey.ai/mcp with sign-in, is for operating Queuey from an AI client such as claude.ai or ChatGPT, not for a coding agent: https://queuey.ai/docs/md/how-to/mcp. 3. Run queuey advise --json. Tell me its reasoning in your own words. If it asks a question (usually: can the network here be trusted?), ask me. Do NOT write your own retry loop, local queue, backoff or offline buffer: that is Queuey.Edge. Then queuey advise --write-files writes queuey.deploy.json, with a secured dev workspace, and .gitignore lines for .env and queuey.json. 4. Log in: queuey login --profile dev --json With addresses I gave you, add --api-base --ingress-base . It prints a link and a code, and exits 5: it waits for me. Show me the link and the code; once I have approved it, run: queuey login --profile dev --wait Do not ask me for an API key: the login is the connection. 5. The workspace. Security goes on the workspace, and its queues inherit it; a new queue is open until its workspace has security. Then: queuey plan --profile dev queuey apply --profile dev A workspace without an environment counts as prod, where keys wait for my approval. Tell me that the security is set on the workspace. 6. The sender's signing key, the same value in Queuey and in the app: queuey keys mint --profile dev --write It writes QUEUEY_SIGNING_KEY_ID and QUEUEY_SIGNING_SECRET. The target is user-secrets in a .NET project with a UserSecretsId, else .env. If I can use neither, run it without --write: nothing is minted, and it names the variables and the console page. Tell me where the key is. Queuey.Client and Queuey.Edge read it with UseSettings(...) or UseEnvironmentVariables(), which reads .env in Development only: https://queuey.ai/docs/md/reference/dotnet-client, and for Edge on a device https://queuey.ai/docs/md/how-to/edge. 7. Configure the receiver for every queue, even if it is outside this repository. Find its actual route and environment from code and deployment settings; ask me if the address is missing. Never guess it or leave a placeholder. Set delivery.url in queuey.deploy.json: an absolute receiver URL, or a route with workspace.delivery.baseUrl. This is not Queuey's ingress address. For local delivery use delivery.kind = "localForward" and the app's route as delivery.url; queuey listen supplies the local host and port. If this code takes Queuey's deliveries, create the receiver's secret: queuey credentials generate --profile dev --write then merge signing into its delivery configuration, keeping url and kind: "delivery": { "signing": { "enabled": true, "templateKey": "queuey", "credentialRef": "" } } Verify every delivery (https://queuey.ai/docs/md/how-to/verify-deliveries), and deduplicate on the event id only with the v2 signature. A hosted Queuey never reaches localhost: to receive on this machine, forward the queue to queuey listen, as https://queuey.ai/docs/md/how-to/local-delivery shows. Run queuey plan and queuey apply with --profile dev. Read back the saved delivery configuration and check the effective receiver, including inherited settings, against the intended endpoint. Do not expose URL credentials. When you start the app to test it, stop it by the PID you started, or with lsof -ti tcp: -sTCP:LISTEN. Never use lsof -ti tcp: alone: it also finds processes connected to the port, such as Queuey delivering to the app, and kills them too. 8. Implement and verify the integration. Wire the publish call into the agreed application path. Start the app (and listener, if local), trigger a harmless representative event through it, and follow that event with: queuey verify --event --profile dev --json If its id is unavailable, use queuey verify --help to start observation before triggering the event. Wait for the final result: a started check is not a pass. Confirm delivery to the intended receiver; check its logs or stored result where accessible. Report the queue, receiver (without secrets), event id and verification outcome. A 202 at ingress or successful apply is not completion. Do not claim setup is complete until delivery is verified. If blocked, report exactly what is missing and the next action needed. 9. Never: - print, read or copy a secret, or use --show-secret; - add --replace without reading me its warning and asking first; - put a key in a command's arguments, in code as a literal, or through /tmp; - work around an approval. In prod a command exits 5 with a link (approval_required): show me the link and wait. You never need a license-wide API key; if one is ever wanted, I mint it in the console at https://app.queuey.ai (the license's Home page → API keys). ``` ## For agents that load skills The same prompt is published as an agent skill, [setup-queuey](https://queuey.ai/.well-known/agent-skills/setup-queuey/SKILL.md), next to `design-event-delivery` in the [skills index](https://queuey.ai/.well-known/agent-skills/index.json). An agent that discovers skills on its own can load it without anyone pasting the prompt. ## Not a .NET repository? The prompt works the same. The CLI is written in .NET, but it does not need .NET to run: every [release](https://github.com/Queuey-AI/queuey-client/releases/latest) ships it as one self-contained file for macOS, Linux and Windows, and the prompt tells the agent how to fetch it. In a JavaScript, TypeScript, Python or Go repository, `queuey advise` does not reach for a package at all: the integration is one POST to the ingress. In a web app, that call belongs in server-side code — an edge function, a route handler, your API — and never in the browser bundle, where the key would be readable by anyone who loads the page. ## The keys, and who holds them Your app publishes with a **signing key** for its workspace, which reaches every queue in it. The agent makes it with your login, `queuey keys mint --write user-secrets` in a .NET project with user secrets, `queuey keys mint --write .env` otherwise, and the value goes straight to where the app reads it: the agent never sees it, and it is the same value Queuey holds. A receiver gets its secret the same way, with `queuey credentials generate --write …`, and checks every delivery with it. When neither place suits you, the agent tells you the variable names and the console page where you make the key yourself. A .NET app reads them with `UseSettings` from configuration, or with `UseEnvironmentVariables()`, which also reads `.env` in Development; in production they come from environment variables, as the [Queuey.Client page](https://queuey.ai/docs/reference/dotnet-client) shows. A license-wide API key is yours, in the console, and the agent never needs one. Queuey Edge signs with the same `QUEUEY_SIGNING_*` key as Queuey.Client. On a device, the Edge daemon reads it from `edge.env`, which the agent makes with `queuey keys mint --queue --write edge.env` and installs as root-only, the way the [Edge page](https://queuey.ai/docs/how-to/edge) shows. Three things the agent never does with a key Put it in a command's arguments, where every user on the machine can read it; write it as a literal in `Program.cs` or any other code; or pass it through `/tmp`. ## If you want the controls You do not have to answer anything for the defaults to work. If you want to steer, the agent tells you where: `queuey plan` shows what `queuey apply` would change, and what the plan's limits would refuse, before anything is written; `queuey.deploy.json` holds the configuration and is committed with the code; and flags change the defaults — `keys mint --queue` for a key to one queue, `--type api-key`, `--write` for where a secret goes. ## Your own Queuey Running Queuey locally or self-hosted? Give the agent its addresses — the API, the ingress, the console, the docs — with the prompt. They win over the queuey.ai addresses in it: it logs in with `--api-base` and `--ingress-base`, the profile keeps both hosts, and every command after that uses them. An agent in a sandbox may not reach `localhost` at all, so a docs server on your machine can be out of its reach. It can still read the docs on queuey.ai: every page at once in [/llms-full.txt](https://queuey.ai/llms-full.txt), or one at a time as Markdown at `/docs/md/{slug}`. ## What happens, in order 1. It asks you one thing first Have you decided where Queuey goes — which part sends, which receives, which services? Point it there, or say “see for yourself”: then it reads the repository, tells you which files it read, and proposes a place before it writes any code. 2. It reads the repository Project files, infrastructure and source. It is looking for two things it can actually determine: whether events already survive a crash somewhere, and whether local storage survives a restart. 3. It works out the path Already have an outbox, a bus or a job queue? Publish from that consumer — no reason to rebuild it. Local disk that survives a restart? That is Queuey Edge. No such disk, as in a container without a volume or a serverless function? Queuey.Client, or one signed POST outside .NET. 4. It asks you the one thing it cannot read Whether the network where this runs can be trusted. That is not in the repository, so it is asked rather than guessed — and only where the answer would change the recommendation. 5. It sets up, and you log in with one link It writes the deployment file and wires up publishing. To connect, it runs queuey login and shows you a link: you check the code and approve it in the console, signing up first if you need to, and it carries on. There is no key to copy. It makes a dev workspace with the security set on it, so every queue inherits it, and tells you, because a new queue is open until its workspace has security. The signing key your code publishes with, and the secret a receiver checks deliveries with, it puts where the app reads them without ever seeing them. 6. It checks the receiver address For every queue, it writes the receiver URL into queuey.deploy.json, applies it and checks the saved destination. It uses the route your application actually handles, with the right host for the environment. If the address is missing, it asks you. 7. It proves the integration works It triggers a harmless event through your application and uses queuey verify to follow it to the receiver. You get the event ID and the verification result. If delivery cannot be verified, it explains what is missing instead of calling the setup complete. 8. In prod, it waits for you A workspace without an environment counts as prod. There, a key goes to a person: the command answers with a link, the agent shows it to you and waits, and it does not try to get around it. ## If you would rather look first This one analyses and changes nothing — no files, no workspace, no credentials. Useful on a repository you do not want touched yet, or when you just want to see what it makes of things. Analyse only ```text Analyse this repository for Queuey (https://queuey.ai) but change nothing. Install the CLI if needed. With .NET 8 or later: dotnet tool install -g Queuey.Cli --prerelease The tool lands in ~/.dotnet/tools. If queuey is not found afterwards, put that folder on PATH as $HOME/.dotnet/tools, for example in ~/.zshrc: export PATH="$PATH:$HOME/.dotnet/tools" Not as "~/.dotnet/tools": a ~ inside quotes is not expanded, and PATH then names a folder that does not exist. No .NET? Every release ships the CLI as one self-contained file, with no runtime needed, for osx-arm64, osx-x64, linux-x64, linux-arm64 and win-x64 (.zip). On macOS or Linux, with this machine's platform in place of osx-arm64: curl -fsSL https://github.com/Queuey-AI/queuey-client/releases/latest/download/queuey-osx-arm64.tar.gz | tar -xz Then move ./queuey onto PATH, or call it as ./queuey. A 404 means there is no build for this machine: install .NET 8 and use the command above. Then run: queuey advise Summarise what it says: do we send, receive or both; which path it recommends; and the evidence it read that from. List the files it would create, but do not create them. If it asks a question it could not answer from the code, put that question to me. ``` ## Why it asks only once If you already know where Queuey goes, it would be a waste to make the agent guess, so it asks first and follows you. If you don't, you don't have to classify your own repository: `queuey advise` reads whether it sends, receives or both, whether events already survive a crash somewhere, and whether local disk survives a restart — the part the tool does better, especially on a codebase you inherited. It does ask about the network, because that genuinely is not in the repository. And it shows the file behind every conclusion, so when it reads something wrong you can see exactly where and say so. Two things the agent will not do - **Mint a license-wide API key.** That is a console action, and it stays yours. A key that can mint keys would turn repository access into account access. The signing key for the workspace it can make, straight into user secrets or a git-ignored .env, without seeing the secret. - **Write its own delivery machinery.** If your producer needs retries, a local buffer or offline survival, the answer is [Queuey Edge](https://queuey.ai/docs/how-to/edge), which already exists. An agent is good at writing two hundred lines of retry logic, which is exactly why it is told not to. ## Without an agent The same command works on its own, and reads more like a second opinion than a tool: bash ```bash queuey advise queuey advise --json # the same, for a machine ``` Or follow the [quickstart](https://queuey.ai/docs/quickstart) and send an event by hand. # Core concepts — workspaces, queues, policies, delivery & events The Queuey mental model: workspaces and queues, the system→workspace→queue policy cascade, delivery targets, the event lifecycle, and how issues surface failing deliveries. Source: https://queuey.ai/docs/concepts --- Getting started # Core concepts The Queuey mental model in seven pieces. Producers POST events to a **queue** in a **workspace**; Queuey applies the **policy cascade**, forwards to your **delivery target**, and gives you the operational tooling to handle whatever fails. WorkspaceThe isolation boundary. Each workspace has its own data and its own queues. Producers address a workspace by its tenantPublicId in the ingress URL (the API calls workspaces tenants). QueueThe unit of configuration and delivery. A queue has a mode (LogOnly or Deliver), a delivery target, and a full set of policies. Two flow levers sit on top: closing ingress refuses new events, and holding delivery keeps them stored without sending. Events flow through a queue on their way to your target. IngressWhere producers POST events: /events/{tenantPublicId}/{queueName}. Ingress enforces auth (API key or HMAC signed-request), an optional source-IP allowlist, idempotency, and loop prevention before an event is accepted. Policy cascadeEvery knob — retries, ordering, payload handling, retention, security — resolves system → workspace → queue, field by field. A queue overrides just the fields it cares about; everything else inherits. Delivery targetThe HTTP endpoint a queue forwards to, plus how Queuey authenticates to it: Bearer, API key, Basic, HMAC signing, or OAuth2 client credentials (including private_key_jwt with a P12 certificate). Event lifecycleAn accepted event is Received, then InProgress as the worker attempts delivery, then Delivered on success. Failures retry under the queue's policy and, when exhausted, land in the dead-letter queue (DLQ). Issues & operationsWhen a target stops responding, Queuey surfaces it as an issue with target-health state, lets you clear and resume a stuck queue, and lets you replay events — so delivery is something you operate, not just attempt. ## Next - [Quickstart](https://queuey.ai/docs/quickstart) — send your first event. - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries, idempotency, and the dead-letter queue. # Sign webhook requests to Queuey (HMAC signed-request auth) Authenticate producers to Queuey ingress with an HMAC signed request: the X-Queuey signature headers, the canonical string, HMAC-SHA256 signing, and replay protection. Source: https://queuey.ai/docs/how-to/signed-requests --- How-to guide # Sign requests to Queuey (HMAC) For higher-trust producers, replace the static API key with an **HMAC signed request**. Each request carries a signature over its method, path, timestamp, nonce, and body hash — so a captured request can't be replayed or tampered with. This page describes Queuey's own scheme, for producers you control. A provider that signs its webhooks its own way is verified with that provider's signing template instead — see [Stripe webhooks](https://queuey.ai/docs/how-to/stripe). ## 1 — Required headers request headers ```http X-Queuey-Key-Id: {keyId} X-Queuey-Timestamp: {unixSeconds} X-Queuey-Nonce: {uniqueNonce} X-Queuey-Content-SHA256: {sha256HexOfRawBody} X-Queuey-Signature: {signatureHex} ``` ## 2 — The canonical string Build the string to sign by joining these six lines with a newline (`\n`), each value normalized exactly as below: canonical string ```text METHOD PATH QUERY TIMESTAMP NONCE CONTENT_SHA256 ``` - `METHOD` — the HTTP method, **uppercase** (`POST`). - `PATH` — the request path exactly as sent, with any trailing slash trimmed (`/events/ten_x/orders`). - `QUERY` — the query parameters **flattened, sorted by key, URL-encoded, and joined with `&`**; an empty string when there are none. This is *not* the raw query string. E.g. `?b=2&a=1` → `a=1&b=2`. - `TIMESTAMP` — the `X-Queuey-Timestamp` value (Unix **seconds**). - `NONCE` — the `X-Queuey-Nonce` value, trimmed. - `CONTENT_SHA256` — the **lowercase hex** SHA-256 of the raw request body bytes. ## 3 — Sign it (HMAC-SHA256) The signature is the lowercase hex HMAC-SHA256 of the canonical string with your signing secret: sign.ts ```typescript import crypto from "crypto"; const secret = process.env.QUEUEY_SIGNING_SECRET!; // the HMAC signing key const body = JSON.stringify({ eventType: "order.created", payload: { id: "10042" } }); const timestamp = Math.floor(Date.now() / 1000); const nonce = crypto.randomUUID(); const bodyHash = crypto.createHash("sha256").update(body).digest("hex").toLowerCase(); const canonical = [ "POST", // METHOD (uppercase) "/events/ten_yourTenant/orders", // PATH (as sent, no trailing slash) "", // QUERY (normalized: flatten+sort+encode; "" if none) String(timestamp), // TIMESTAMP (Unix seconds) nonce, // NONCE (trimmed) bodyHash, // CONTENT_SHA256 (lowercase hex sha256 of raw body) ].join("\n"); const signature = crypto .createHmac("sha256", Buffer.from(secret, "utf8")) .update(Buffer.from(canonical, "utf8")) .digest("hex") .toLowerCase(); // send X-Queuey-Key-Id / Timestamp / Nonce / Content-SHA256 / Signature ``` Replay protection Queuey rejects a signed request whose timestamp is out of range or whose nonce has been seen before — so sign each request fresh with a new timestamp and nonce. Common rejection reasons: `invalid_timestamp`, `timestamp_out_of_range`, `invalid_signature`. ## Related - [Verify deliveries](https://queuey.ai/docs/how-to/verify-deliveries) — the reverse: check Queuey's signature on your receiver. - [Headers & status codes](https://queuey.ai/docs/reference/headers) — the full header and response reference. # Verify a Queuey webhook delivery (HMAC signature verification) Verify that an inbound webhook really came from Queuey: recompute the HMAC-SHA256 signature over the canonical request, compare in constant time, and reject stale timestamps. Source: https://queuey.ai/docs/how-to/verify-deliveries --- How-to guide # Verify a delivery on your receiver When a queue signs its outbound deliveries with Queuey HMAC, every request to your endpoint carries the same `X-Queuey-*` signature headers. Verify them so you only act on requests that genuinely came from Queuey — and reject replays. A queue that receives Stripe webhooks can sign in Stripe's format instead; those deliveries are verified with Stripe's libraries. ## The checks 1. Read the signing headers; reject if any is missing. 2. Reject stale timestamps (a few minutes of clock skew). 3. Recompute the body hash from the **raw** bytes — never from a re-serialized JSON object — and reject it unless it equals `X-Queuey-Content-SHA256`. 4. Rebuild the canonical string — the method, the path **decoded** (all but `%2F`), the **normalized** query, the timestamp, the nonce and the body hash, one per line — and HMAC-SHA256 it with your secret. The query is normalized as Queuey signs it: each key and value decoded (`+` is a space), the pairs sorted by key and then by value, each re-encoded (RFC 3986), joined with `&`. So `?sort=name&page=2&tag=a+b&tag=%C3%A9` is signed as `page=2&sort=name&tag=a%20b&tag=%C3%A9`. For v2, add the event id and the idempotency key as lines seven and eight. 5. Compare to the `v2=` entry of `X-Queuey-Signatures` in constant time — or, for a delivery from before v2 only, to `X-Queuey-Signature`. ## Verify (TypeScript / Node) verify.ts ```typescript import crypto from "crypto"; // %XX decoded as UTF-8; bytes that are not valid UTF-8 stay as they are, // as .NET's Uri.UnescapeDataString and ASP.NET Core's path decoding leave them. function decodePercent(text) { return text.replace(/(%[0-9A-Fa-f]{2})+/g, (run) => { try { return decodeURIComponent(run); } catch {} const bytes = run.match(/%[0-9A-Fa-f]{2}/g); let out = ""; for (let i = 0; i < bytes.length; ) { let n = Math.min(4, bytes.length - i); for (; n > 0; n--) { try { out += decodeURIComponent(bytes.slice(i, i + n).join("")); break; } catch {} } if (n === 0) { out += bytes[i]; n = 1; } i += n; } return out; }); } // The path as Queuey signs it: percent-decoded, as ASP.NET Core reads a // request path, except %2F, which stays encoded. // /hooks/caf%C3%A9/order%2F1/a%20b is signed as /hooks/café/order%2F1/a b. function canonicalPath(rawPath) { return (rawPath || "/").split(/(%2[Ff])/) .map((part, i) => (i % 2 === 1 ? part : decodePercent(part))) .join(""); } // The query as Queuey signs it: decode each key and value ("+" is a space), // sort by key, then by value, re-encode (RFC 3986), join with "&". function normalizeQuery(rawQuery) { if (!rawQuery || !rawQuery.trim()) return ""; const raw = rawQuery.startsWith("?") ? rawQuery.slice(1) : rawQuery; if (!raw.trim()) return ""; const decode = (s) => decodePercent(s.replace(/\+/g, " ")); // encodeURIComponent leaves !'()* alone; RFC 3986 escapes them. const encode = (s) => encodeURIComponent(s) .replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()); const pairs = raw.split("&").filter((segment) => segment.length > 0).map((segment) => { const i = segment.indexOf("="); return i < 0 ? [decode(segment), ""] : [decode(segment.slice(0, i)), decode(segment.slice(i + 1))]; }); const ordinal = (a, b) => (a < b ? -1 : a > b ? 1 : 0); pairs.sort((x, y) => ordinal(x[0], y[0]) || ordinal(x[1], y[1])); return pairs.map(([key, value]) => encode(key) + "=" + encode(value)).join("&"); } // Express-style handler. IMPORTANT: verify against the RAW body bytes, // before any JSON parsing/re-serialization. export function verifyQueueyDelivery(req, secret, { acceptV1 = false } = {}) { const keyId = req.get("X-Queuey-Key-Id"); const timestamp = req.get("X-Queuey-Timestamp"); const nonce = req.get("X-Queuey-Nonce"); const contentHash = req.get("X-Queuey-Content-SHA256"); if (!keyId || !timestamp || !nonce || !contentHash) return false; // 1. Reject stale deliveries (±5 minutes). const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(skew) || skew > 300) return false; // 2. Recompute the body hash from the raw bytes; it must match the header. const bodyHash = crypto.createHash("sha256") .update(req.rawBody) // Buffer of the raw request body .digest("hex").toLowerCase(); if (contentHash.trim().toLowerCase() !== bodyHash) return false; // 3. The six lines v1 signs, and v2 signs first. const lines = [ req.method.trim().toUpperCase(), // METHOD canonicalPath(req.path), // PATH, decoded except %2F normalizeQuery(req.originalUrl.split("?")[1] ?? ""), // QUERY, normalized timestamp, // TIMESTAMP nonce, // NONCE bodyHash, // CONTENT_SHA256 ]; const hmac = (text) => crypto .createHmac("sha256", Buffer.from(secret, "utf8")) .update(Buffer.from(text, "utf8")) .digest("hex"); const same = (provided, expected) => { const a = Buffer.from(provided.trim().toLowerCase(), "utf8"); const b = Buffer.from(expected, "utf8"); return a.length === b.length && crypto.timingSafeEqual(a, b); }; // 4. v2, in X-Queuey-Signatures as v2=: the six lines, then the event id // and the idempotency key, trimmed, empty when the delivery has none. const v2 = (req.get("X-Queuey-Signatures") ?? "").split(",") .map((part) => part.trim()) .find((part) => part.startsWith("v2=")) ?.slice(3); if (v2) { const eventId = (req.get("X-Queuey-Event-Id") ?? "").trim(); const idempotencyKey = (req.get("Idempotency-Key") ?? "").trim(); return same(v2, hmac([...lines, eventId, idempotencyKey].join("\n"))); } // 5. Only a delivery from before v2: v1, in X-Queuey-Signature. It does not // cover the event id or the idempotency key, so do not deduplicate on them. const v1 = req.get("X-Queuey-Signature"); return acceptV1 && Boolean(v1) && same(v1, hmac(lines.join("\n"))); } ``` It verifies v2 and refuses a delivery that carries only v1. Pass `{ acceptV1: true }` to take those too — deliveries made before v2 — and do not deduplicate on the event id or the idempotency key of a delivery that only v1 verified. ## What the signature covers, and the event id In .NET, `QueueyDeliveryVerifier` does all of this (see [Queuey.Client](https://queuey.ai/docs/reference/dotnet-client)). What it checks, and what any receiver should: - The signing headers must be there: `X-Queuey-Key-Id`, `X-Queuey-Timestamp`, `X-Queuey-Nonce`, `X-Queuey-Content-SHA256`, and the signature — `X-Queuey-Signatures` for v2, `X-Queuey-Signature` for v1. - The timestamp must be within five minutes of your clock. - The SHA-256 of the **raw** body must equal `X-Queuey-Content-SHA256`. The signature covers that header, not the bytes, so this check is what ties the signature to the body. - **v1**, in `X-Queuey-Signature` as hex: an HMAC-SHA256 with your secret over six lines, `METHOD\nPATH\nQUERY\nTIMESTAMP\nNONCE\nCONTENT_SHA256`. PATH is percent-decoded as ASP.NET Core reads a request path, except `%2F`, which stays encoded: `/hooks/caf%C3%A9/order%2F1/a%20b` is signed as `/hooks/café/order%2F1/a b`. QUERY is normalized: each key and value decoded, sorted, and re-encoded. - **v2**, in `X-Queuey-Signatures` as `v2=` (a comma-separated list, one entry per version): the same HMAC over eight lines, the six of v1 and then `EVENT_ID` (`X-Queuey-Event-Id`) and `IDEMPOTENCY_KEY` (`Idempotency-Key`), each trimmed, and empty when the delivery has none. A delivery carries both versions, over the same timestamp, nonce and body hash. - With a nonce guard (`NonceAlreadySeen` in .NET), a nonce seen before is rejected. Without one, the five-minute window is the only protection against the same delivery sent again. **Deduplicate on the event id or the Idempotency-Key only with v2.** A retry or a redelivery of one event carries the same id, each time with a new nonce, so the id is what to deduplicate on. v2 covers both values: a delivery with another event id or key needs a new valid signature, which takes the secret. v1 covers neither, so with v1 alone whoever captured a delivery can send it again within the five minutes under another event id, and it verifies; a nonce guard narrows that, since the capture's nonce is then spent. Verify the raw body Frameworks that parse JSON before your handler runs will break verification — the bytes you hash must be exactly what Queuey sent. Capture the raw body (e.g. `express.raw()` or a rawBody hook) and hash that. ## Deliveries signed in Stripe's format A queue whose ingress verifies Stripe webhooks can use **Stripe signature recalculation**. Its deliveries carry `Stripe-Signature` instead of `X-Queuey-Signature` and the other `X-Queuey-*` signature headers, so the checks above do not apply to them. Verify them the way you verify a request from Stripe: with Stripe's library (`constructEvent` and its default tolerance), the raw body, and the secret the queue signs with: the same `whsec_…` secret its Stripe ingress verifies with. `t` is the time of the delivery attempt, and every retry or replay is signed afresh, so deduplicate on the Stripe event's `id`. Queuey signs only the unchanged bytes of events that arrived through the queue's Stripe-verified ingress after recalculation started; what that means, and what it refuses to sign, is in [Stripe webhooks, step 5](https://queuey.ai/docs/how-to/stripe). ## Related - [Signed requests (HMAC)](https://queuey.ai/docs/how-to/signed-requests) — the same scheme, applied to requests into Queuey. - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — use the Idempotency-Key to dedupe on your side too. - [Stripe webhooks](https://queuey.ai/docs/how-to/stripe) — Stripe signature recalculation, from the Stripe endpoint to your handler. # OAuth2 private_key_jwt webhook auth with a P12 certificate Authenticate webhook delivery to a target with OAuth2 client credentials — including private_key_jwt signed with a P12 certificate — with cached tokens. Source: https://queuey.ai/docs/how-to/oauth2-private-key-jwt --- How-to guide # OAuth2 delivery auth with private_key_jwt & a P12 certificate Some targets — regulated APIs, financial and government systems, enterprise ERPs — require **OAuth2 client credentials** rather than a static bearer token, and often the `private_key_jwt` method signed with a **certificate**. Queuey fetches and caches the access token for you, then signs each delivery with it. ## How it works On a queue's delivery target, set the auth mode to **OAuth2 (client credentials)** and pick a client authentication method: - `client_secret_post` / `client_secret_basic` — a shared secret, stored as an OAuth2 client-secret credential. - `private_key_jwt` — a **P12 certificate** (plus its passphrase) is uploaded as a credential; Queuey builds a signed JWT client assertion and never sends the private key. You configure the token endpoint, client id, scope, and (optionally) audience. Queuey requests a token, caches it across workers until just before it expires, and attaches `Authorization: Bearer ` to every delivery. ## 1 — The signed client assertion For `private_key_jwt`, Queuey builds and signs this JWT with your certificate (RS256/ES256, with the `x5t` thumbprint in the header): client assertion (claims) ```json { "iss": "", "sub": "", "aud": "https://idp.example.com/connect/token", "jti": "", "iat": 1750000000, "nbf": 1750000000, "exp": 1750000120 } ``` ## 2 — The token request Queuey posts the assertion to the IdP's token endpoint as a client-credentials grant: POST token endpoint ```http POST /connect/token HTTP/1.1 Host: idp.example.com Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &scope= &client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer &client_assertion= ``` Use the discovery token_endpoint Point the token endpoint at the IdP's actual `token_endpoint` (from its `.well-known/openid-configuration`), e.g. `/connect/token` — not the portal or discovery base, which will return HTML. ## 3 — The delivery Each delivery now carries the bearer token. If the target needs to know which of your customers an event belongs to, forward the event's GroupKey as a header the target expects (for example `X-Account-Id`): outbound delivery ```http POST /your/webhook HTTP/1.1 Host: api.partner.example Authorization: Bearer Content-Type: application/json X-Account-Id: { "eventType": "invoice.created", "payload": { ... } } ``` Security defaults The token endpoint must be HTTPS (plain HTTP is allowed only for localhost). The certificate and passphrase are stored encrypted, and the access token is cached only for its lifetime minus a safety skew. Only the public certificate is ever sent to the IdP. ## Related - [Source-IP allowlist](https://queuey.ai/docs/how-to/ip-allowlist) — pair OAuth2 with a network gate for high-trust integrations. - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries, idempotency, and DLQ. # Restrict webhook ingress by source IP (CIDR allowlist) Lock a queue's ingress to known source IPs with a per-queue CIDR allowlist — a network gate evaluated before auth, matching the real client IP and refusing everything else with 403. Source: https://queuey.ai/docs/how-to/ip-allowlist --- How-to guide # Restrict ingress to known source IPs When you know exactly which network a producer comes from — a partner's static egress, a government API's published range — you can refuse everything else at the door. Queuey's per-queue **source-IP allowlist** is a network gate evaluated *before* authentication. ## Configure the allowlist On the queue's ingress settings, add CIDR ranges or bare IPs (a bare address is treated as a `/32` or `/128`). IPv4 and IPv6 are both supported: ipAllowlistCidrs ```json [ "203.0.113.0/24", "198.51.100.7", "2001:db8::/32" ] ``` - **Unset follows the workspace.** A queue with no list of its own uses its workspace's. With no list on either, the queue accepts any source IP — the gate only bites once ranges are listed. - **A list set on a queue replaces the workspace's** — it does not union with it. An empty list counts: a queue with every range removed accepts any source IP, even when its workspace has a list. - To put a queue back on its workspace's list, use **Reset to workspace** in the queue's ingress settings. That resets the queue's other ingress settings to the workspace's as well. ## What a blocked request sees A request whose client IP is outside every range is refused before any auth or crypto runs: Response ```http HTTP/1.1 403 Forbidden Content-Type: application/json { "error": { "code": "ip_not_allowed", "message": "…" } } ``` Matches the real client IP — not a spoofable header The allowlist matches the connection's real client IP (`RemoteIpAddress`). Queuey deliberately does not trust a client-supplied `X-Forwarded-For` header, so a forged header cannot move the matched IP or bypass the allowlist. ## Related - [OAuth2 & private_key_jwt](https://queuey.ai/docs/how-to/oauth2-private-key-jwt) — combine a network gate with strong delivery auth. - [Core concepts](https://queuey.ai/docs/concepts) — where ingress sits in the pipeline. # Reliable webhook delivery — retries, idempotency & DLQ Configure retries with exponential backoff and jitter, failure-class retry gates, idempotency, safe Hold-on-timeout behaviour, and a dead-letter queue for webhook delivery you can trust. Source: https://queuey.ai/docs/how-to/reliable-delivery --- How-to guide # Reliable webhook delivery Networks fail, receivers restart, endpoints rate-limit. Queuey turns that into a delivery you can trust: **retries** with backoff, **idempotency** so a retry is never a duplicate, a safe **Hold-on-timeout** default, and a **dead-letter queue** for whatever truly can't be delivered. ## Retries & backoff Each queue resolves a retry policy. What gets retried is decided by the **failure class**, not by a counter: a receiver that rejected the event goes straight to the dead-letter queue, while a target that is down is held and probed until it recovers. `dlq.afterAttempts` is the one attempt budget you set. Retries use exponential backoff with jitter, and what counts as retryable is driven by **failure classes** (server errors, unavailable, rate-limited) with per-receiver HTTP-status overrides: retry policy ```yaml Retry policy (resolved system → tenant → queue) backoff: exponential, base 1s, max 5m, jitter: full retryOn: failure classes (server error, unavailable, rate-limited) + httpStatus fallback / excluded + network errors, timeouts timeoutBehavior: Hold # Retry | Hold (default) | Dlq dlq: enabled, afterAttempts: 8 ``` ## Idempotency Send a producer `Idempotency-Key` and a duplicate retry within the dedup window short-circuits to the already-accepted event — you never double-enqueue: ingress with Idempotency-Key ```bash curl -X POST "$INGRESS/events/ten_yourTenant/orders" \ -H "X-Api-Key: $KEY" \ -H "Idempotency-Key: order-10042-created" \ -H "Content-Type: application/json" \ -d '{ "eventType": "order.created", "payload": { "id": "10042" } }' ``` On the way out, Queuey threads the same key (and an `X-Queuey-Path` for loop prevention) so an idempotent receiver can dedupe across delivery retries too: outbound headers ```http POST /your/webhook HTTP/1.1 Idempotency-Key: order-10042-created X-Queuey-Path: que_yourQueue Content-Type: application/json ``` ## Safe-by-default on timeout Hold, don't blindly retry When a delivery times out with no response, Queuey doesn't know if the receiver applied the side effect. The default `timeoutBehavior` is **Hold**: the event is held, the target is marked as requiring action, and the queue stops, so later events wait too — they carry the same risk. Queuey opens an issue rather than risk a duplicate. Switch to `Retry` only once you've asserted the endpoint is idempotent — or route timeouts straight to `Dlq`. ## Dead-letter queue When retries are exhausted (or after a configured attempt count), the event lands in the DLQ instead of disappearing. From the console you can inspect the failure, fix the target, and **replay** — single events or in bulk. ## Related - [Core concepts](https://queuey.ai/docs/concepts) — the event lifecycle and where retries fit. - [Quickstart](https://queuey.ai/docs/quickstart) — send your first event. # Operate webhook delivery — diagnose, resume & replay failures When a target goes down: read target-health, clear a requires-action lock to resume a stuck queue, inspect the dead-letter queue, and replay failed events — single or in bulk. Source: https://queuey.ai/docs/how-to/operations --- How-to guide # Operational runbook: a target went down When a receiver fails, follow the issue from the first alert to a verified recovery: **notify → investigate → verify → resume**. Use the console yourself or connect an agent to help investigate. Two kinds of target trouble **Probing** — a transient outage; the circuit is open and Queuey is auto-probing to recover. It resumes on its own, no action needed. **Requires action** — a sticky failure (e.g. auth rejected); the queue holds and waits for you to fix the target and resume. Knowing which is which is half the job. ## Set up notifications Open **Notification destinations** on your license page in the console. Add an email address or a Slack incoming webhook, choose the minimum severity and save. Use **Send test** and check that the alert arrives at the intended destination. To notify an agent, choose an agent destination where that option is available. Queuey supports a signed webhook to your own HTTPS endpoint or a Claude Code routine. The setup guide in the console walks through the connection. Start with **Lasting delivery problems**to focus the agent on issues that persist or get worse. A notification starts the investigation. Access to Queuey is configured separately through [MCP](https://queuey.ai/docs/how-to/mcp). Give the agent the access its job needs; the console's routine guide starts with **Investigate (read and suggest)**. Connecting MCP by itself does not subscribe the agent to alerts. ## Example: your receiver starts returning 401 1. Queuey holds deliveries and raises an issue because the receiver rejected its credentials. 2. When the issue matches your notification settings, Queuey alerts your team or starts the connected agent. 3. The agent reads the issue and delivery evidence through MCP, checks the application context it has access to, and records its findings on the issue. 4. Correct the credentials, then verify that a delivery reaches the receiver. Apply any required production approval before changing Queuey. 5. Resume held deliveries and replay failed events as needed. Check the outcome and leave a note explaining the fix. Test both parts: an alert reaching the agent, and the agent successfully reading the issue. A successful test notification alone does not prove the agent has the right Queuey permissions. ## Investigate and recover 1. 1 — See what's wrong When a target stops accepting deliveries, Queuey surfaces it as an issue on the queue with a target-health state, instead of silently burning retries. The events pile up as held rather than lost. 2. 2 — Read the diagnosis Each issue carries a rule-based diagnosis (and an incident report) explaining what failed and why — the status codes, the failure class, when it started. You don't reverse-engineer it from raw logs. 3. 3 — Fix the target, then clear & resume Once the endpoint is healthy again, clear the requires-action lock to resume the queue. Held events flow again from where they stopped — in order, for ordered queues. 4. 4 — Replay what failed Events that exhausted their retries are in the dead-letter queue. Inspect them, then replay — a single event, or the whole filtered set in bulk — once the underlying cause is fixed. 5. 5 — Close it out Resolving the issue produces an after-action summary, so the next person (or the next incident) starts with context instead of a cold trail. ## Why this matters The issue, delivery history and recovery steps stay together. Your team and your agent can see what failed, what has been checked and which events still need to be delivered. ## Related - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries, idempotency, Hold-on-timeout, and DLQ. - [Core concepts](https://queuey.ai/docs/concepts) — the event lifecycle and issue model. # Ordered webhook delivery & per-key lanes Deliver events in strict order — across the whole queue, or per key in independent lanes — with concurrency control and no head-of-line blocking between keys. Source: https://queuey.ai/docs/how-to/ordering --- How-to guide # Ordered delivery & per-key lanes Some events must arrive in order — an `order.updated` can't overtake its `order.created`. Queuey controls ordering on two independent axes: **how events are grouped** and **how strictly they're ordered** within that group. ## The two axes | Axis | Options | What it controls | | --- | --- | --- | | Partition | None · ByKey | Whether the queue is one stream (None) or split into independent lanes by a key (ByKey). | | Ordering | BestEffort · Fifo | How strictly events are ordered within the scope — the whole queue (None) or each lane (ByKey). | ## Whole-queue ordering With `Partition = None` and `Ordering = Fifo`, the queue is a single ordered stream. A `concurrency` cap bounds how many deliveries run at once. Simple and strict — but one slow event holds up everything behind it. ## Per-key lanes With `Partition = ByKey`, each distinct key (e.g. an account, an order id, a customer) becomes its own independent lane, ordered within itself. Tune them with `concurrencyPerKey` and `maxActiveKeys`. No head-of-line blocking across keys A stuck event in customer A's lane never blocks customer B's lane — they advance independently. You get strict per-customer order *and* parallelism across customers. The lane key is derived from the event's context (see [Customer context](https://queuey.ai/docs/how-to/customer-context)). ## Related - [Customer context (GroupKey)](https://queuey.ai/docs/how-to/customer-context) — where the lane key comes from. - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries and DLQ inside each lane. # Carry customer context — extract & forward a GroupKey Extract a customer/account identifier (GroupKey) from each event's header, query, or body at ingress, persist it as context, and forward it to your target as a header. Source: https://queuey.ai/docs/how-to/customer-context --- How-to guide # Carry customer context with a GroupKey When one queue carries events for many of *your* customers, the **GroupKey** is the thread that says *whose* event this is. Queuey extracts it at ingress, keeps it on the event as context, and can forward it to your target — so the customer identity travels end-to-end. ## 1 — Extract it at ingress Configure where the GroupKey comes from — a query parameter, a header, or a top-level field in the body. One source per key: extraction sources ```http # 1) From a query parameter POST /events/ten_yourTenant/orders?key=ACME # 2) From a header POST /events/ten_yourTenant/orders X-Customer-Id: ACME # 3) From a field in the JSON body (envelope) POST /events/ten_yourTenant/orders { "customer": "ACME", "eventType": "order.created", "payload": { ... } } ``` The resolved GroupKey is persisted on the event, so you can filter and observe by customer in the console. ## 2 — Forward it downstream Opt in to forward the GroupKey as an outbound header (you choose the name) so your receiver knows which customer an event belongs to: delivery ```http POST /your/webhook HTTP/1.1 Authorization: Bearer X-Account-Id: ACME # the GroupKey, forwarded downstream Content-Type: application/json { "eventType": "order.created", "payload": { ... } } ``` ## 3 — Forward more than the GroupKey The GroupKey is one value with one purpose. When your receiver needs more of the original request — a trace id, a region, an order reference — map any number of **outbound headers** from the same three sources. Each mapping is *a name you choose ← where it comes from*: delivery with mapped headers ```http POST /your/webhook HTTP/1.1 X-Account-Id: ACME # GroupKey, as before X-Trace-Id: 7f3a… # header:X-Acme-Trace — forwarded from the request X-Region: eu-north # query:region — from ?region= X-Order-Ref: ORD-1042 # envelope:orderRef — from the body Content-Type: application/json { "orderRef": "ORD-1042", "eventType": "order.created", "payload": { ... } } ``` - **Resolved once, at accept.** The value is read from the original request and stored on the event, before any payload transform. Every delivery attempt — a retry days later included — sends exactly what arrived. Changing the mapping does not rewrite events already queued. - **Missing means omitted.** If a source holds nothing on a given event, that header is simply left out and the event still delivers. Mark a mapping *required* only if you want ingress to reject events without it (HTTP 400) — that is an availability decision for the producer, not a delivery detail. - **Check it against real traffic before saving.** The console replays a draft mapping over your last events and shows how many resolved, with example values — so a misspelt field is obvious now, not when the receiver complains. Header and query sources have no history to check (they were never stored); body sources do. Headers end up in logs Receivers, proxies and CDNs routinely log request headers — even when they do not log bodies. Do not map body fields that carry personal data, and never map anything that looks like a credential. The console refuses the known credential and transport header names outright (see [the reference](https://queuey.ai/docs/reference/headers)) and warns on names that merely look like one. Mapped headers, like the GroupKey header, are **not covered by request signing** — treat them as context your receiver can use, not as a claim it can verify. The GroupKey is also your lane key Use the GroupKey as the partition key and each customer gets its own ordered [lane](https://queuey.ai/docs/how-to/ordering) for free — strict per-customer order, parallel across customers. ## Optional: a separate partition key The lane key can equal the GroupKey, or be extracted separately when you want to order by something finer-grained than the customer (e.g. an account within a customer). Both are derived at ingress from the same header / query / body sources. ## Related - [Ordering & per-key lanes](https://queuey.ai/docs/how-to/ordering) — turn the GroupKey into per-customer ordering. - [Core concepts](https://queuey.ai/docs/concepts) — where context fits in the pipeline. # Webhook payload formats — raw, normalized JSON or CloudEvents v1 Your receiver gets the payload as it was published, as normalized JSON or as a CloudEvents v1 event. JSON Patch mutations reshape it on the way out, and privacy levers decide how long Queuey keeps it and who can see it. Source: https://queuey.ai/docs/how-to/payloads --- How-to guide # Payloads: what your receiver gets Your receiver gets the payload as it was published, as normalized JSON, or as a CloudEvents v1 event — you choose per workspace or queue. A JSON Patch mutation changes it on the way out when you set one, and three privacy levers decide how long Queuey keeps it and who can look at it. ## What the receiver gets With the default format, *Raw (as received)*, and no payload mutations, the request body is the bytes Queuey accepted, unchanged — on the first attempt, on every retry and on a replay. Nothing is added to it: no envelope, no metadata fields. The `Content-Type` is the media type the event was published with, without parameters such as `charset`, or `application/octet-stream` when it had none. what the receiver gets ```http POST /orders HTTP/1.1 Host: api.example.com Content-Type: application/json Idempotency-Key: order-10042-paid X-Queuey-Event-Id: evt_… { "orderId": "10042", "status": "paid" } ``` The event's identity travels in headers: by default `Idempotency-Key` — the same on every attempt — and `X-Queuey-Event-Id`. The [headers reference](https://queuey.ai/docs/reference/headers) lists the headers a delivery carries, including those your delivery authentication and request signing add. ## Payload format Set it under *Delivery → Delivery behavior → Payload & ordering → Payload format*, on a workspace or on one queue. The format is applied after any JSON Patch and before signing, so an HMAC signature covers the bytes the receiver gets — verify it over the body you received. | Format | What the receiver gets | | --- | --- | | Raw (as received) | The body as it was published, after any JSON Patch. The default, and the only format for forwarding a provider's signature and for Stripe signature recalculation. | | Normalized JSON | A body published as JSON, parsed and written back compact: whitespace gone, every value unchanged. A body not published as JSON, or one that doesn't parse, goes as it is. | | CloudEvents v1 | The event wrapped as a CloudEvents v1 event in structured mode, sent as application/cloudevents+json. | Formats chosen before they applied Until the payload format applied at delivery, every receiver got the body as published, whatever was selected. When it started to apply, queues and workspaces that had normalized JSON or CloudEvents v1 stored were set back to **Raw (as received)**, so no receiver's body changed without a choice. Pick the format again if you want it. ## CloudEvents v1 For receivers that speak the **CloudEvents** spec, the event goes out in structured mode: the payload becomes `data`, with the event's context alongside. Every value comes from the stored event, so a retry and a replay send the same bytes. a CloudEvents delivery ```http POST /hooks/orders HTTP/1.1 Content-Type: application/cloudevents+json; charset=utf-8 { "specversion": "1.0", "id": "evt_nd9qgy1sjPIW", "source": "/queues/que_Rp2yPELnp4sr", "type": "order.shipped", "time": "2026-09-30T08:15:00.0000000Z", "subject": "acme", "datacontenttype": "application/json", "data": { "orderId": "10042", "status": "shipped" } } ``` | Attribute | Value | | --- | --- | | id | The event's id — the same value as the X-Queuey-Event-Id header. | | source | /queues/ and the queue that delivers the event. For a Queuey Connect copy it is the producer's stream, the same for every integrator. | | type | The event type (X-Queuey-Event-Type, or wherever the queue reads it from). ai.queuey.event when the event has none. | | time | X-Queuey-Occurred-At when the producer sent it; otherwise when Queuey received the event. A Queuey Connect copy keeps the producer's time. | | subject | The event's customer key (GroupKey), when it has one. | | datacontenttype | The media type the event was published with. | | data | JSON content as JSON and text as a string; anything else, or bytes that aren't valid UTF-8, as data_base64. | ## Change the body on the way out Payload mutations apply **JSON Patch** (RFC 6902) operations to the outbound body — add a field, strip an internal one — without touching the producer. Set them under *Delivery → Delivery behavior → Payload mutations*, on a queue, or on the workspace to apply them to every queue in it: JSON Patch mutation ```json [ { "op": "add", "path": "/source", "value": "queuey" }, { "op": "remove", "path": "/internalNote" } ] ``` - Queuey applies the `add`, `remove` and `replace` operations. A patch that contains any other operation — `move`, `copy`, `test` — is skipped. - Patches run in order at delivery time, on the stored body — so a patch you add or change also applies to retries and replays of events already in the queue. - A patch that cannot be applied to an event — a `replace` of a field the event does not have, say — is skipped as a whole, and the event is delivered without it. Removing a field that is not there is not an error. - A patched body is sent as compact JSON with the event's `Content-Type`. A body that is not JSON is delivered unchanged. The payload format applies after the patch, so under CloudEvents v1 the patched body is the event's `data`. Raw and no mutations keep signatures valid For provider webhooks your receiver already verifies (a signature over the raw body), keep the queue on **Raw (as received)** and without payload mutations — its own and the workspace's — so the bytes stay exactly as the provider signed them. Queuey doesn't forward the signature header by default. The [Stripe guide](https://queuey.ai/docs/how-to/stripe) is the worked example, including forwarding that header as a mapped header. ## Payload privacy Three levers on a workspace or queue (Delivery → Delivery behavior → Payload privacy) decide how much of a payload exists and who can look at it. **Payload in the console**: `shape` (default — the event list and detail show the payload's structure, never its values; an operator clicks Reveal, and that look is written to the payload access log), `values`, or `shapeOnly` (values are never shown in the console — Reveal is refused for everyone and AI analysis is capped at Shape; delivery and the API are unaffected). **Payload after delivery**: `untilRetention` (default — replay possible) or `deleteOnSuccess` (the payload is deleted the moment the event is delivered; status, attempts and a values-free shape stay, delivered events can no longer be replayed). **Receiver responses**: `onFailure` (default), `always` or `never` — when the receiver's response body and headers are kept per attempt. You can also cap body size with `maxBytes`. ## Related - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — stored bodies make replay trivial. - [Headers reference](https://queuey.ai/docs/reference/headers) — the headers a delivery carries. - [Queuey Connect](https://queuey.ai/docs/how-to/connect) — what integrators receive, and the CloudEvents option for them. - [Core concepts](https://queuey.ai/docs/concepts) # Distribute event streams to integration partners with Queuey Connect Publish an event stream once. Integration partners subscribe from their own Queuey workspace, set their endpoint, and receive only the customers you activate. Source: https://queuey.ai/docs/how-to/connect --- How-to guide # Distribute event streams to integration partners with Queuey Connect Queuey Connect is webhooks as a service, built on your queues. You publish an event stream once. The integration partners you invite — *integrators*, in the console — subscribe to it from a workspace on their own Queuey account, point it at their own endpoint, and receive the events for the customers you activate for them. Every integrator gets its own copy in its own queue, with its own retries, dead-letter queue and replay, so an integrator whose endpoint is down holds up neither you nor the other integrators. ## How an event reaches an integrator A stream does not deliver to an endpoint of its own. For each event it accepts, Queuey writes a copy into the queue every subscribed integrator has for that stream — when both of these hold: - **The integrator subscribes to the stream.** Integrators subscribe themselves, to any stream you have published and not blocked for them. - **You have activated the event's customer key for that integrator.** The customer key — the GroupKey — says which of your customers the event belongs to. You send it with every event. An event without a customer key is stored on the stream and copied to no one. | In the console | What it is | | --- | --- | | Stream | A queue in your workspace that you have published. Integrators see its name, description, event types and payload schema. | | Integrator | An integration partner you invited by email. It receives in a workspace on its own Queuey account. | | Customer key | The GroupKey on each event: which of your customers it belongs to. You activate customer keys per integrator, and an activation covers every stream that integrator takes. | | Package | A named group of streams. An integrator can subscribe to a whole package at once, and you can block one for an integrator in one step. | ## As the producer ### 1 — Publish a stream Streams live in a workspace of type *Integration hub*. Create one under [New workspace](https://app.queuey.ai/console/t/new), or turn a workspace you already have into one with *Enable integrations* on its Integrations page (`app.queuey.ai/console/t//integrations`). Either way the Integrations view becomes the workspace's dashboard, with *Streams*, *Packages* and *Integrators*; queues already in the workspace keep running. Under *Streams*, *New stream* takes a name and, optionally, the event types the stream carries, a description and a JSON Schema of the payload. Integrators see the name, event types and description when they browse, and the schema's fields once they subscribe, where their filter builder offers them. The name is also the name of the stream's queue and part of the address you publish to: lowercase letters, digits, `.`, `-` and `_`, starting with a letter or digit, up to 64 characters. `sandbox` is reserved. ### 2 — Check who can publish A stream copies what it accepts to every integrator activated for the event's customer key, so only your own code should be able to publish to it. A stream you create under *New stream* (or with `PUT /waas/streams`) requires an API key, unless the workspace already demands a key or a signed request; a publish without a valid key is refused with `401`, including one that is only signed. Publishing a queue you already have as a stream keeps that queue's authentication. The requirement is set on the stream's queue when the stream is created, so it doesn't follow later changes to the workspace's setting. The stream's overview shows who can publish under *Publishing*, and its *Security* tab changes it. ### 3 — Invite an integrator *Invite integrator*, with the address of the person who will set up the receiving side. Queuey emails them a link, and the invitation also shows on the console home of anyone signed in with that address. Only that address can accept it. The integrator is *Pending* until they do. ### 4 — Activate customers Once the integrator has accepted, open them under *Integrators* and choose *Activate a customer*. Enter the customer key exactly as you send it on events: keys are compared as written, so `ACME` and `acme` are two customers. From the next event on, that customer's events on every stream the integrator subscribes to reach them. *Deactivate* stops it from the next event; copies already in the integrator's queue stay there. ### 5 — Publish events Publishing to a stream is an ordinary publish to its queue, with two more headers: whose event it is, and what kind. A *Limit to ingress* key scoped to the workspace is all the publishing code needs: publish to the orders stream ```bash curl -X POST "https://ingress.queuey.ai/events/ten_yourTenant/orders" \ -H "X-Api-Key: qak_yourKey" \ -H "X-Queuey-Group-Key: acme" \ -H "X-Queuey-Event-Type: order.shipped" \ -H "Idempotency-Key: order-10042-shipped" \ -H "Content-Type: application/json" \ -d '{ "orderId": "10042", "customerId": "acme", "status": "shipped" }' ``` - `X-Queuey-Group-Key` — the customer key. Without it, the event is copied to no one. - `X-Queuey-Event-Type` — the event type integrators see and filter on. - `Idempotency-Key` — a retried publish with the same key is stored once within the dedup window (24 hours by default), and integrators receive the key on every delivery attempt, so they can deduplicate too. - The body is what integrators receive — as you sent it, unless an integrator chooses another payload format. Put the customer's id in it too: by default, the customer key and the event type reach the request only when the integrator chooses CloudEvents v1. Queuey answers `202 Accepted` once the event is stored. The copies are written after that, so an integrator never slows down your publish. ## As the integrator The receiving side runs in the Queuey console, on the integrator's own account. ### 1 — Accept the invitation The link opens the invitation. Signed in with the invited address, the integrator chooses which of their accounts receives — one they own or administer — or, with no account yet, names their organization and Queuey creates a free account. No payment details are needed. They name the workspace, and Queuey creates it on that account. A receiving workspace and its stream queues don't count against the account's workspace and queue limits. ### 2 — Subscribe *Discover* lists the streams the producer has published and not blocked for them, that they don't take yet, grouped by package. *Subscribe* takes one stream; *Subscribe to all* takes every stream in a package that has more than one. Each subscription gives the integrator a queue for that stream in their workspace, listed under *Your streams*. That queue has no endpoint yet. Until it has one, it stores what arrives without sending it, and those events are not sent later on their own — so set the endpoint when you subscribe. ### 3 — Set the endpoint *Delivery* holds the workspace's defaults, which every stream inherits: a base URL, how Queuey authenticates to the endpoint, request signing, a rate limit, a timeout, and how failures are handled — retries, the dead-letter queue, how long events are kept. On a stream, *Endpoint* either inherits the base URL and adds a path such as `/orders`, or overrides it with a full URL and its own authentication: none, a bearer token or an API key header, with optional HMAC-SHA256 signing the endpoint can verify. A receiving workspace delivers in one lane per customer: each customer's events arrive in the order they were published, and a customer whose event is held doesn't hold up the others. When the producer gives its events a finer lane key of its own, such as one per order, each customer gets a lane per key of theirs, so two customers never share a lane. When the endpoint looks down — failures on two customers' lanes, or on the only lane with events — Queuey holds the events and probes the endpoint until it recovers. An event that fails for one customer while the others deliver is retried on that customer's lane, and goes to the dead-letter queue if it keeps failing. *Delivery → Delivery behavior → Payload & ordering → Ordering* changes this. *By customer (lanes)* is the default for a receiving workspace you create; one set up before lanes were the default, or an existing workspace you link, keeps its ordering and can switch there. ### 4 — Filter, if you only want part of a stream *Filter* keeps the events that match: conditions on top-level fields of the JSON payload, or on `$eventType`, compared with `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains` or `exists`, and combined with *And* or *Or*. An event that doesn't match is not copied to the integrator and costs nothing. Copies already waiting when the filter changes are checked again before delivery; those that no longer match are kept as *Filtered* instead of sent. ### 5 — What arrives Each copy is delivered by the integrator's queue as an ordinary Queuey delivery — here to a base URL of `https://hooks.partner.example/queuey` with the path `/orders` and a bearer token: what the integrator's endpoint receives ```http POST /queuey/orders HTTP/1.1 Host: hooks.partner.example Content-Type: application/json Authorization: Bearer Idempotency-Key: order-10042-shipped X-Queuey-Event-Id: evt_… # the integrator's copy X-Queuey-Path: que_…,que_… # the stream, then the integrator's queue { "orderId": "10042", "customerId": "acme", "status": "shipped" } ``` The body is the payload as the producer published it. `Idempotency-Key` is the key the producer published with — or the one Queuey assigned when it accepted the event — and it is the same on every attempt and replay. `X-Queuey-Event-Id` names the integrator's copy. Authentication and signing are the integrator's own settings — [Verify deliveries](https://queuey.ai/docs/how-to/verify-deliveries) shows how to check a signature. That is the default, *Raw (as received)*. To receive the customer key and the event type in the request, choose *CloudEvents v1* under *Delivery → Delivery behavior → Payload & ordering → Payload format*: the copy then arrives as a CloudEvents event, with the event type as `type`, the customer key as `subject` and the payload as `data` — see [Payload formats](https://queuey.ai/docs/how-to/payloads). Signing covers the bytes sent, so verify the signature over the CloudEvents body. Either way, the event type stays with the copy, and the stream's *Events* view in the integrator's console filters by it. The customer key is stored with the copy and decides its lane. From here it is the integrator's queue. Failures are retried or held by type, an event that cannot be delivered goes to the dead-letter queue instead of disappearing, and every attempt is recorded with the endpoint's answer. Inspect, skip and replay from the stream's *Events* view — see the [operational runbook](https://queuey.ai/docs/how-to/operations). ## Control what each integrator receives Everything you publish is open to every integrator who has accepted an invitation from you. Two levers narrow it, per integrator: - **Customer keys.** An integrator gets only the customers you activated for them. - **Blocks.** On an integrator's page, block a stream they take (from its row under their subscriptions), or a whole package, for that integrator. A block stops new subscriptions and pauses the ones they have; routing to them stops within seconds, and events published while the block holds are not kept for them. Unblocking resumes the subscription — endpoint and filter intact — from the next event. A stream in a blocked package stays blocked even when another package it belongs to is not. ## Who pays The producer pays for distribution. The event it publishes and every copy written to an integrator's queue are metered on the producer's tokens, one token per started 16 KB of payload. A copy the integrator's filter rejects is not billed. Receiving costs the integrator nothing; inspecting and skipping are free, and a replay costs that event's tokens on the integrator's own account, which a free account includes. ## What Connect does not do - It does not send one event to several endpoints from one queue. Each integrator receives from its own queue, with its own endpoint and failure handling — which is why one integrator's outage never holds up another. When the receivers are your own systems, the receiving side can be your own account: invite an address on your team and choose your account when accepting. - It does not distribute an event without a customer key. - A stream does not also deliver to an endpoint of its own. - By default, it does not add the customer key or the event type to the request an integrator receives; CloudEvents v1 carries both in the body. - It does not show the producer an integrator's endpoints, credentials or events. The producer sees who is invited, which streams each integrator takes, which customers are activated for them, and how many of each stream's events were handled over the last seven days. - Filters read top-level JSON fields only, and one integrator takes at most 1,000 streams. Over the API The producer steps are the [WaaS producer API](https://queuey.ai/docs/reference/waas-api) — `PUT /waas/streams` (declarative, safe to repeat), `POST /waas/integrations`, `POST` and `DELETE /waas/activations`, packages and blocks. From a script or a deploy, call them with a *Build (set up and publish)* key from the *API keys* tab on the license's Home page, scoped to the workspace, and the license id (`lic_…`, shown under the license name there) in `X-License-PublicId`. `queuey sync` in the Queuey CLI applies the streams a .NET codebase declares with the same key. A *Limit to ingress* key only publishes events, and *Full access* cannot manage Connect. ## Related - [WaaS producer API](https://queuey.ai/docs/reference/waas-api) — every producer endpoint. - [Customer context (GroupKey)](https://queuey.ai/docs/how-to/customer-context) — the customer key on a queue outside Connect. - [Verify deliveries](https://queuey.ai/docs/how-to/verify-deliveries) — check the HMAC signature on what arrives. - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries, idempotency and the dead-letter queue on the receiving side. # Loop prevention for Queuey-to-Queuey delivery (X-Queuey-Path) When queues deliver to other Queuey endpoints, the X-Queuey-Path chain plus a hop budget and cycle detection stop events from looping forever. Source: https://queuey.ai/docs/how-to/loop-prevention --- How-to guide # Loop prevention for Queuey-to-Queuey delivery When a queue delivers to another Queuey endpoint — building an event mesh across teams or tenants — you risk an event looping forever. Queuey threads a path header so it can detect and refuse loops. ## The X-Queuey-Path chain Each queue appends its own public id to `X-Queuey-Path` when it delivers onward, comma-separated, so every event carries the trail of queues it has passed through. Ingress stores the chain as it arrived. An event that passed through `que_a` and `que_b`, delivered onward by `que_c`, carries: X-Queuey-Path ```http X-Queuey-Path: que_a,que_b,que_c ``` ## Two protections - **Cycle detection** — if the receiving queue's own id already appears in the incoming path, the event is refused (it would loop). - **Hop budget** — a configurable max chain length (default 8). An event arriving with more hops is refused with `422`, catching misrouted topologies before they form an exact cycle. It just works for mesh topologies You don't configure anything per event — the path is threaded automatically on outbound delivery and checked on ingress. Raise the hop budget only when you have a documented reason; set it to 0 to disable the cap (cycle detection still runs). ## Related - [Headers & status codes](https://queuey.ai/docs/reference/headers) — X-Queuey-Path and the 422 response. - [Core concepts](https://queuey.ai/docs/concepts) # Queuey Edge — durable offline publishing from unreliable networks (.NET) Publish events durably from factories, kiosks, vehicles and on-prem services: one PublishAsync call commits locally (SQLite) and Queuey handles transfer, retries, reconnect and backlog draining. Source: https://queuey.ai/docs/how-to/edge --- How-to guide # Queuey Edge — durable publishing from unreliable networks For producers where the network is a fact of life, not a guarantee — factories, kiosks, vehicles, ships, on-prem services. One `PublishAsync` call durably accepts the event on the local machine; Queuey owns the delivery mechanics from there. ## The contract When `PublishAsync` returns, the event is committed to a local durable store (SQLite, fsync'd) under a **permanent transfer identity**. Queuey then handles transfer to Queuey Cloud, retries with backoff and jitter, reconnect, lost-acknowledgement resolution, idempotent resend, recovery across process and machine restarts, and backlog draining — in order per lane. Kill the network for two days: events accumulate durably and drain when it returns. A retry, however late, can never become a second logical event. **Ordering, precisely.** Events reach Queuey Cloud in strict accept order within a lane — the queue, or the queue plus `GroupKey` — with at most one transfer in flight per lane and no ordering across lanes. Whether that order is kept through to your destination is the queue's own ordering policy (`ordering: fifo` keeps it; `besteffort` does not). An event Cloud permanently rejects is quarantined and steps aside so the lane keeps moving; `queuey edge retry` puts it back at the head of its lane. **Draining a backlog.** Edge has no rate limiter of its own: transfers run with bounded concurrency across lanes, back off with decorrelated jitter, probe with a single event after a failure, and wait at least Cloud's `Retry-After` (plus a little jitter, so a fleet that went dark together does not knock again in lockstep). Before Queuey.Edge ```csharp // ❌ What Queuey.Edge exists to delete from your codebase if (!await queuey.IsOnline()) { await localDb.Save(evt); ScheduleRetry(...); } ``` ## Set it up once Install the `Queuey.Edge` NuGet package (`dotnet add package Queuey.Edge --prerelease`), make the node's signing key with your `queuey login`, and register it. Edge signs with the same `QUEUEY_SIGNING_KEY_ID` and `QUEUEY_SIGNING_SECRET` as [Queuey.Client](https://queuey.ai/docs/reference/dotnet-client), read by the same rules. A key on a machine you do not control should reach nothing but its queue, so make it with `--queue`: Package version Signing with the queue's key requires Queuey.Edge ≥ 0.1.0-preview.9 (newer than 0.1.0-preview.8), and the queuey CLI of the same version for `keys mint --write`. 0.1.0-preview.8 and older publish with an API key only. bash ```bash queuey keys mint --queue sensor-readings --write .env # or --write user-secrets in a .NET project ``` Program.cs ```csharp // Program.cs — any .NET host (worker service, ASP.NET, console) builder.Services.AddQueueyEdge(o => { o.UseEnvironmentVariables(); // QUEUEY_SIGNING_KEY_ID + QUEUEY_SIGNING_SECRET (and QUEUEY_TENANT) // or, with user-secrets: o.UseSettings(builder.Configuration); // Optional: o.Storage.Path = "/var/lib/myapp/queuey/spool.db"; }); ``` Edge signs each transfer when it sends it, so an event that waited in the spool through an outage goes out with a fresh timestamp and nonce. The secret stays in memory; the spool never holds it. The timestamp is this machine's clock, and the ingress refuses one more than five minutes off (`timestamp_out_of_range`), so keep the clock synchronised (NTP): with a clock that is off, Edge holds the events, says the clock is the cause, and drains once it is right. A workspace that takes signed requests only accepts Edge as it accepts the SDK. A publish-only API key (`QUEUEY_API_KEY`) still works for a workspace that takes API keys. As with Queuey.Client, `.env` and user secrets are read only in Development, and `dotnet run` without a launch profile runs as Production: set `DOTNET_ENVIRONMENT=Development` (or `ASPNETCORE_ENVIRONMENT`) while you develop. Edge sends to `https://ingress.queuey.ai`. Against a local or self-hosted Queuey, give it that Queuey's ingress with `QUEUEY_INGRESS_BASE`, which `UseEnvironmentVariables()` and the daemon read, or set `IngressBaseAddress` in code: bash ```bash # the environment, or edge.env for the daemon; UseEnvironmentVariables() reads it QUEUEY_INGRESS_BASE=http://localhost:5084 # the daemon also takes it as a flag queuey edge run --spool /var/lib/queuey/spool.db --ingress-base http://localhost:5084 ``` Program.cs ```csharp builder.Services.AddQueueyEdge(o => { o.UseEnvironmentVariables(); o.IngressBaseAddress = new Uri("http://localhost:5084"); // or leave it to QUEUEY_INGRESS_BASE }); ``` Where a key never goes Not in a command's arguments (every user on the machine can read argv), not as a literal in `Program.cs` or any other code, and never through `/tmp`. ## Then publish — that's the whole app SensorService.cs ```csharp // The whole delivery story in application code: public sealed class SensorService(IQueueyPublisher queuey) { public async Task OnReading(Reading reading) => await queuey.PublishAsync("sensor-readings", reading, new PublishOptions { EventType = "temperature.updated", GroupKey = reading.DeviceId, // ordering lane OccurredAtUtc = reading.ReadAtUtc, // honest history when a backlog drains }); } ``` Honest history Pass `OccurredAtUtc` and a backlog drained on Wednesday still reads as Monday in the console — Queuey stores the occurrence time separately from the receive time (`X-Queuey-Occurred-At`). ## What can actually go wrong at the call `PublishAsync` throws only for conditions that exist *before* Queuey takes responsibility: missing configuration, a payload that cannot be serialized or exceeds your local cap, a full local store (`QueueySpoolFullException` — the honest backpressure of lossless retention), or a corrupt store awaiting explicit operator recovery. Network state, Queuey Cloud availability and HTTP errors never surface at the call site. Storage rules Local durable disk only (never a network share; in containers, a persistent volume), and exclude `spool.db*` from antivirus and file-copy backups. Default 512 MB spool ≈ a year of offline autonomy at 1 event/minute × 1 KB. The limit counts live events: a spool that filled up during an outage accepts again as soon as its backlog has transferred. ## No .NET app: the Edge daemon On a Linux box or IoT gateway, `queuey edge run` is the whole of Edge as a systemd service, and anything on the machine publishes durably to it. It reads the signing key from its environment file, never from flags. Make the file on your own machine and stream it straight into place, owned by root and readable by nobody else, then delete your copy: bash ```bash queuey keys mint --queue sensor-readings --write edge.env # the secret is never shown ssh device 'sudo install -d -m 700 -o root -g root /etc/queuey' ssh device 'sudo install -m 600 -o root -g root /dev/stdin /etc/queuey/edge.env' < edge.env && rm edge.env ``` systemd ```ini # /etc/systemd/system/queuey-edge.service (excerpt) [Service] ExecStart=/opt/queuey/queuey edge run --spool /var/lib/queuey/spool.db --listen 7311 EnvironmentFile=/etc/queuey/edge.env User=queuey StateDirectory=queuey # /etc/queuey/edge.env (root:root, 0600) QUEUEY_TENANT=ten_... QUEUEY_SIGNING_KEY_ID=hsk_... QUEUEY_SIGNING_SECRET=... # QUEUEY_EDGE_HEALTH_API_KEY=qak_... # only for --report-health # QUEUEY_INGRESS_BASE=https://ingress.example.internal # only when the ingress is not Queuey's own ``` `--report-health` (or `Health.ReportToCloud` in an app) shows the node under *Edge nodes* in the console. Queuey's check-in takes an API key today, not a signature, so a node that signs checks in with `QUEUEY_EDGE_HEALTH_API_KEY`, a publish-only key (`queuey keys mint --type api-key`) that nothing but the check-in uses: in `edge.env` for the daemon, in the environment or user secrets for an app. Without it the node says so once and sends no reports; its events flow as before. When Queuey takes a signed check-in, the health key goes away. The full runbook is in [docs/edge-operations.md](https://github.com/Queuey-AI/queuey-client/blob/main/docs/edge-operations.md). ## Queuey exposes health — you monitor it Edge publishes an OpenTelemetry meter and an in-process snapshot for whatever monitoring you already run (Azure Monitor, Datadog, Prometheus, SCADA). If you wire exactly one alert, use `oldest_age_seconds`: it grows when *anything* has been stopping transfer, and Edge resumes by itself when the cause clears. Health signals ```text Meter: Queuey.Edge (OpenTelemetry) queuey.edge.spool.pending events accepted locally, not yet transferred queuey.edge.spool.oldest_age_seconds ← THE signal worth alerting on queuey.edge.spool.quarantined permanently rejected, awaiting operator retry/discard queuey.edge.cloud.last_contact_seconds seconds since last successful transfer queuey.edge.state 0 Healthy · 1 Backlogged · 2 RequiresAction 3 StorageFull · 4 StorageFaulted queuey.edge.transfer.accepted{replayed} / transfer.failed{class,reason} ``` ## Operator verbs The `queuey` CLI operates a spool file directly, alongside a running host. Discard reaches *only* quarantined events, and data loss is always an explicit, acknowledged decision — never Queuey's default. CLI ```bash queuey edge status --spool [--json] queuey edge retry --spool (--id N | --all) # after fixing a quarantine cause queuey edge discard --spool --id N # explicit, logged operator decision queuey edge recover --spool # salvage a faulted spool queuey edge reset --spool --accept-data-loss # start clean; old file preserved ``` Next steps - Runnable demo (kill Queuey, watch events survive and drain): [samples/Queuey.Edge.Demo](https://github.com/Queuey-AI/queuey-client/tree/main/samples/Queuey.Edge.Demo) - Sending from a reliably connected service instead? The plain [quickstart](https://queuey.ai/docs/quickstart) (one HTTP POST) is all you need. - What happens after Cloud accepts: [reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) and [operating deliveries](https://queuey.ai/docs/how-to/operations). # Trigger Queuey from Supabase database webhooks Turn Supabase row changes into durable events: connect the project from the console and Queuey installs the Database Webhook — or point one at your ingress URL by hand. Retries, DLQ, ordering and delivery history, no code. Source: https://queuey.ai/docs/how-to/supabase --- How-to guide # Trigger Queuey from Supabase database webhooks A Supabase **Database Webhook** fires an HTTP request whenever a row changes. Point it at your Queuey ingress URL and every insert, update or delete becomes a durable event — with retries, a dead-letter queue, ordering and full delivery history — before it reaches whatever should react to it. No SDK, no code: connect the project from the console and Queuey installs the webhook for you — or set it up by hand with one URL and one header. ## Connect automatically (recommended) The console can install the webhook for you. Open [Integrations](https://app.queuey.ai/console/integrations) in the Queuey console, click *Connect* on Supabase, and approve the access request on supabase.com (it is an organization-level grant — pick the organization that owns the project). Then choose a workspace, a project and one or more tables, tick the operations you want (insert, update, delete), and click *Create queue & install trigger*. For each table Queuey: - creates a queue named after the table (`orders` for `public.orders`) that **requires** an API key — a request without it is refused with `401`; - mints a key that can publish to *that one queue only*, so a leaked trigger header cannot reach anything else you own; - installs a Database Webhook trigger named `queuey_` in your project — visible under *Integrations → Database Webhooks* like any hook you made by hand, with a 5-second timeout; - labels every event with the operation (`INSERT`, `UPDATE`, `DELETE`) read from the body, so the console and your delivery rules can tell them apart. The queue starts in *Log-only*: change a row, watch it arrive, then set a destination under the queue’s *Delivery* settings when you are ready. Supabase itself does not retry the request it sends to Queuey; everything after the `202` is durable. Bodies above roughly 64 KB are not sent by Supabase’s `pg_net` at all — keep wide rows out of webhook tables or send ids and let the receiver fetch. Removing and disconnecting *Remove* on a table drops the trigger and revokes its key; the queue and its events are kept until you delete the queue yourself. *Disconnect* does that for every table under the connection and then revokes the token grant. If you revoke the grant on supabase.com instead (*Organization → Integrations*), the console shows the connection as *Revoked*: Queuey can no longer reach the project to remove its triggers, so *Disconnect* revokes their keys — a stranded trigger can only get `401` — and you drop the `queuey_*` triggers yourself. Queuey never stores your database credentials: the grant is an OAuth token, encrypted at rest, used only to list your projects and tables, run the trigger SQL, and check that the trigger still exists. ## Or by hand: 1 — Create the webhook Prefer to keep Queuey out of your Supabase organization? The manual route is one URL and one header. In the Supabase dashboard, open *Integrations → Database Webhooks* (the first time, click *Install integration* — it enables the `pg_net` extension), then *Create a new hook*: webhook configuration ```yaml Name: orders-to-queuey Table: public.orders Events: Insert, Update, Delete Type: HTTP Request Method: POST URL: https://ingress.queuey.ai/events/ten_yourTenant/orders Headers: Content-Type: application/json X-Api-Key: qak_8fK2mNpQ.aB3dE7gH9jK1mN4pR6tV8wX0yZ ``` The URL and the `X-Api-Key` value come from your Queuey console — see the [quickstart](https://queuey.ai/docs/quickstart) if you have neither yet. That is the whole integration: Supabase delivers the row change to Queuey, and Queuey answers `202 Accepted` once the event is durably stored. ## 2 — What arrives on the queue Supabase sends a fixed JSON shape: the operation, the table, and the row (`old_record` is filled on updates and deletes). Queuey accepts it as-is — no envelope required: what Supabase sends ```json { "type": "INSERT", "table": "orders", "schema": "public", "record": { "id": 1042, "customer_id": "ACME", "status": "placed", "created_at": "2026-08-31T14:38:36.69Z" }, "old_record": null } ``` what Queuey answers ```http HTTP/1.1 202 Accepted Content-Type: application/json { "queuePublicId": "que_Rp2yPELnp4sr", "eventId": "evt_nd9qgy1sjPIW", "receivedAtUtc": "2026-08-31T14:38:36.71Z", "mode": "Deliver", "replayed": false } ``` From here the event behaves like any other Queuey event: it shows up in the console, delivers to the queue’s target with the queue’s retry policy, and lands in the dead-letter queue instead of disappearing when the target will not take it. mode: LogOnly? Give the queue a target A fresh queue has no delivery target, so it accepts and stores events without forwarding them — the ack says `"mode": "LogOnly"`. To deliver, open the queue in the console, set its endpoint under *Delivery*, and switch the queue to *Deliver*. Events accepted while the queue was log-only stay inspectable in the console. ## 3 — Telling operations apart The operation travels in the body: `type` says whether the row was inserted, updated or deleted, and `table` says where. Your receiver branches on those two fields. When different tables deserve different handling — separate targets, separate retry policies, separate ordering — give each table its own webhook pointing at its own queue (`orders`, `invoices`, …) rather than parsing everything out of one stream. Do not set a static Idempotency-Key header An `Idempotency-Key` header on the webhook would carry the *same* value on every row change — and Queuey would correctly deduplicate all but the first event away. Leave it unset. Supabase’s delivery can occasionally duplicate a request; make the final receiver idempotent instead ([reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery)) — a duplicate you can ignore beats a row change you never heard about. ## Prefer SQL? The webhook is a trigger Dashboard webhooks are ordinary Postgres triggers under the hood. If you keep your schema in migrations — or you are letting an AI agent set the project up — create the trigger directly: migration-friendly version ```sql -- The dashboard webhook is just a trigger. Creating it in SQL means it -- lives in your migrations and survives a project rebuild. create trigger orders_to_queuey after insert on public.orders for each row execute function supabase_functions.http_request( 'https://ingress.queuey.ai/events/ten_yourTenant/orders', 'POST', '{"Content-Type":"application/json","X-Api-Key":"qak_…"}', '{}', '5000' -- timeout in ms; the 202 ack is fast, but do not go lower ); ``` ## Why not point Supabase straight at your receiver? You can — and it works until the receiver has a bad minute. Database Webhooks are sent via `pg_net`: **fire-and-forget**, with a short timeout, no durable retry, no dead-letter queue, and no way to see afterwards what was sent or what failed. If your endpoint is down, deploying, or rate-limited, that row change is simply gone. Putting Queuey’s ingress in between turns the fragile hop into the shortest one — a fast 202 from an endpoint built to say yes — and every hop after that is retried, observable and replayable. ## Related - [Deliver to a BuildShip workflow](https://queuey.ai/docs/how-to/buildship) — the natural next hop: Supabase → Queuey → BuildShip. - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries, idempotency and the dead-letter queue. - [Ordering & per-key lanes](https://queuey.ai/docs/how-to/ordering) — keep changes to the same row in order. # Reference: Queuey headers & ingress status codes Reference for the headers Queuey reads and sends (X-Queuey-Event-Type, X-Queuey-Path, Idempotency-Key, the X-Queuey signature set) and the ingress HTTP status codes you can get back. Source: https://queuey.ai/docs/reference/headers --- Reference # Headers & status codes The headers Queuey reads on ingress and sends on delivery, and the HTTP status codes you can get back when you POST an event. ## Headers Queuey reads (ingress) | Header | Meaning | | --- | --- | | Content-Type | application/json for JSON event bodies. | | X-Api-Key | API-key authentication. Format: qak_. (when the queue's ingress auth is API key). | | Idempotency-Key | Producer-asserted idempotency key. A duplicate within the dedup window short-circuits to the already-accepted event. | | X-Queuey-Event-Type | The event's type, such as order.created: what the console shows and filters on, and what integrators of a stream see. Every queue reads it, unless the queue's or the workspace's ingress takes the event type from somewhere else (a query parameter or a body field). | | X-Queuey-Key-Id | Signed-request: the HMAC signing key id. | | X-Queuey-Timestamp | Signed-request: Unix seconds, checked for skew (replay protection). | | X-Queuey-Nonce | Signed-request: a unique value, single-use (replay protection). | | X-Queuey-Content-SHA256 | Signed-request: lowercase hex SHA-256 of the raw body. | | X-Queuey-Signature | Signed-request: lowercase hex HMAC-SHA256 of the canonical string. | | X-Queuey-Path | Loop-prevention chain. Refused past the configured hop budget or on a cycle. | ## Setting the event type From the .NET SDK, set `EventType` on `PublishOptions`, or pass it to `PushEventAsync`; the client sends it as `X-Queuey-Event-Type` (see [.NET producer: Queuey.Client](https://queuey.ai/docs/reference/dotnet-client)). Over plain HTTP, send the header yourself: bash ```bash curl -X POST "https://ingress.queuey.ai/events/ten_yourTenant/orders" \ -H "Content-Type: application/json" \ -H "X-Queuey-Event-Type: order.created" \ -d '{ "orderId": "10042" }' # …plus the queue's credential: the X-Queuey signature headers, or X-Api-Key ``` Every queue reads this header, with nothing to set up. The type can come from a query parameter or a top-level body field instead, such as Stripe's `type`: set `"ingress": { "eventType": { "from": "body", "name": "type" } }` on the workspace or the queue in `queuey.deploy.json`, or event typing on the queue's *Events* tab in the console. Then no producer has to send the header, and a queue with such a source reads the type from there instead. ## Headers Queuey sends (delivery) | Header | Meaning | | --- | --- | | Authorization | Bearer / Basic — set per the target's auth mode (incl. OAuth2). | | Idempotency-Key | Threaded from the event so an idempotent receiver can dedupe across retries. The v2 signature covers it. | | X-Queuey-Event-Id | The event's public id, the same on every retry and redelivery: what a receiver deduplicates on. The v2 signature covers it. | | X-Queuey-Path | Queuey appends its segment, so a downstream Queuey can detect loops. | | X-Queuey-Signature (+ Key-Id / Timestamp / Nonce / Content-SHA256) | Present when the queue signs outbound deliveries with Queuey HMAC: the v1 signature, as hex, over method, path, query, timestamp, nonce and body hash. | | X-Queuey-Signatures | Beside v1: the signatures by version, as v2=. v2 adds the event id and the Idempotency-Key to v1's lines. See Verify deliveries. | | Stripe-Signature | Present instead, when the queue recalculates the Stripe signature: t=,v1=. Signed afresh on every attempt with the secret the queue's Stripe ingress verifies with, and only over the unchanged bytes of events that arrived through that ingress after recalculation started. Verify it with Stripe's libraries — see Stripe webhooks, step 5. | | Your GroupKey header | Opt-in: the event's GroupKey under a name you choose (see Customer context). | | Your mapped headers | Opt-in: values lifted off the inbound request at accept — a header, a query parameter, or a top-level body field — sent under names you choose. Snapshotted per event, identical on every retry. See Customer context → Forward more than the GroupKey. | | Your static headers | Fixed name/value pairs configured on the target. Always win a name collision. X-Queuey-Event-Id and Idempotency-Key can't be static headers: Queuey sets them per event, and signs them. | Precedence on a name collision, lowest to highest: mapped headers, Queuey's own headers, then static headers / auth / signing. The console refuses a collision at save; the order is the backstop. With Stripe signature recalculation on, `Stripe-Signature` is written by the signing step alone: a static header, a mapped header or a GroupKey header with that name is refused at save. **Signing does not cover mapped or GroupKey headers.** The HMAC canonical string is built from method, path, query, body and the signature headers themselves — a forwarded header travels unsigned. Treat it as context, not as a verified claim. A recalculated Stripe signature covers less still: the timestamp and the body, as Stripe's own scheme does. ## Names that can never be forwarded | Header | Why | | --- | --- | | X-Queuey-* | Queuey's own vocabulary — the whole prefix, including the HMAC headers. | | Idempotency-Key, X-Idempotency-Key, X-Delivery-Key | Dedup identity; forwarding one would let a producer forge it. | | Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-Api-Key | Credentials. Forwarding one hands the producer's secret to every receiver of the queue. | | Host, Content-Type, Content-Length, Connection, Transfer-Encoding, Expect, Upgrade, TE | Transport — the outbound request owns these. | ## Ingress status codes Errors return a consistent envelope so clients can branch on the code: `{ "error": { "code": "ingress_closed", "message": "…" } }`. | Status | Meaning | | --- | --- | | 202 Accepted | The event was accepted and persisted. The body returns the eventId; delivery is asynchronous. A duplicate Idempotency-Key returns 202 with replayed:true. | | 400 Bad Request | Validation error — context_group_key_required (the queue requires a GroupKey and none was found); context_group_key_invalid, context_partition_key_invalid or context_event_type_key_invalid (the key contains control characters); context_group_key_too_long, context_partition_key_too_long or context_event_type_key_too_long (the key is over the length limit); outbound_header_required (a mapped header marked required found no value); sandbox_query_not_allowed (sandbox query parameters on the production URL). | | 401 Unauthorized | Missing or invalid credentials — missing_api_key, invalid_api_key, invalid_signature, timestamp_out_of_range, replayed_request. | | 403 Forbidden | Valid but denied — ip_not_allowed, client_scope_denied, missing_permission. | | 404 Not Found | Unknown workspace or queue — not_found. | | 409 Conflict | The queue's ingress is closed — ingress_closed; events already accepted keep delivering. The queue or its workspace is archived — queue_archived; it takes no events until someone restores it in the console. A queue with no valid mode is refused too — queue_paused. | | 422 Unprocessable Entity | The X-Queuey-Path chain exceeded the hop budget or formed a cycle — loop_detected / max_hops_exceeded. | ## Related - [Signed requests (HMAC)](https://queuey.ai/docs/how-to/signed-requests) — how the X-Queuey signature headers are built. - [Stripe webhooks](https://queuey.ai/docs/how-to/stripe) — when a delivery carries Stripe-Signature, and what Queuey checks before it signs. - [Customer context](https://queuey.ai/docs/how-to/customer-context) — forward the GroupKey and other request values as headers. - [Quickstart](https://queuey.ai/docs/quickstart) — send your first event. # Deliver events to a BuildShip workflow Put a Queuey queue in front of a BuildShip API trigger: events reach the workflow with retries, per-key ordering and a DLQ — instead of fire-and-forget HTTP. Source: https://queuey.ai/docs/how-to/buildship --- How-to guide # Deliver events to a BuildShip workflow A BuildShip workflow with an API trigger is an HTTP endpoint — which makes it a perfectly ordinary Queuey delivery target. Put a queue in front of it and every event reaches the workflow with retries, per-key ordering and a dead-letter queue, instead of depending on the workflow being up and fast at the exact moment the event happened. ## 1 — Get the workflow’s URL In BuildShip, give the workflow a *REST API Call* trigger, ship it, and copy the endpoint URL. If you protect the trigger (you should), note the header name and key it expects. ## 2 — Point a queue at it In the Queuey console, set the queue’s target to the trigger URL and pick the auth mode that matches what the trigger expects: queue target ```yaml Target URL: https://.buildship.run/orders-sync Auth: API key (header name + value your trigger expects) — or Bearer / Basic / OAuth2 client credentials ``` Each delivery now POSTs the event body to the workflow. With a Supabase-fed queue (see [the Supabase guide](https://queuey.ai/docs/how-to/supabase)), the workflow receives the row change exactly as Supabase shaped it: what the workflow receives ```http POST /orders-sync HTTP/1.1 Host: .buildship.run X-Api-Key: Content-Type: application/json { "type": "INSERT", "table": "orders", "schema": "public", "record": { "id": 1042, "customer_id": "ACME", "status": "placed" }, "old_record": null } ``` A `2xx` from the trigger counts as delivered. Anything else — timeouts, `5xx`, rate limits — is handled by the queue’s retry policy, and gives up into the dead-letter queue rather than into thin air. ## 3 — Keep the workflow idempotent Delivery is at-least-once: a retry after an ambiguous failure can run the workflow twice for one event. Have the workflow treat some stable value — the row’s `record.id` plus the operation, or the [`Idempotency-Key` header](https://queuey.ai/docs/reference/headers) Queuey sends, the same on every attempt — as “already processed” and exit early on a repeat. If the workflow’s effect is *not* safe to repeat (it charges, emails, or increments), say so in the queue’s retry policy: Queuey’s default on an ambiguous timeout is to **hold and ask**, not to blindly retry — see [reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery). Answer fast, work slow BuildShip workflows can run long — AI nodes especially. Configure the trigger to respond as soon as the work is accepted rather than when it finishes; a trigger that takes longer than the delivery timeout looks like a failing target and will be retried. Accept fast, then let the workflow run. ## Why not trigger BuildShip straight from Supabase? Supabase Database Webhooks are sent fire-and-forget via `pg_net`: short timeout, no durable retry, no record of what failed. If the workflow is redeploying, cold, or rate-limited when the row changes, the trigger never happened — and BuildShip’s own retry settings cannot help, because they retry nodes *inside* a workflow that already started, not triggers that never arrived. With Queuey in between, the fragile hop is a fast 202 into durable storage, and the hop to BuildShip is retried until the workflow takes it — with the full attempt history in the console when you want to know why something was slow. ## Related - [Trigger Queuey from Supabase](https://queuey.ai/docs/how-to/supabase) — the other half of the chain. - [Ordering & per-key lanes](https://queuey.ai/docs/how-to/ordering) — run the workflow once at a time per customer, in order. # Do you need a queue in front of your webhook handler? What Stripe, Shopify and GitHub do when your webhook handler fails — and what a queue in between adds: alerting, replay, search and AI diagnosis. Source: https://queuey.ai/docs/how-to/provider-webhooks --- How-to guide # Do you need a queue in front of your webhook handler? Stripe, Shopify, GitHub, a partner's system — whoever sends you webhooks has already decided how long to wait for your answer, how often to try again, and when to give up. Your handler has to live up to the least patient of them, every hour of every day, including the afternoon of a bad deploy. A queue in between changes whose patience matters: the provider talks to an ingress built to accept and store, and everything after that runs on your terms. This page is the honest version of when that is worth it — and when it is not. ## What providers do when your handler fails | Provider | Wants an answer within | Its own retries | After that | | --- | --- | --- | --- | | Stripe | “Quickly”, before any real work | Up to three days, with exponential backoff | Retries stop. Manual resend, event by event, for 15 days in the Dashboard or 30 with the CLI. | | Shopify | 5 seconds | Up to 8 times in 4 hours | After repeated failures the webhook subscription itself is removed. | | GitHub | 10 seconds | None — a failed delivery is not redelivered automatically | Manual redelivery, or a script of your own against the API. | | Supabase database webhooks | A short timeout | None — fire-and-forget | The row change is gone. | From each provider's own documentation, September 2026. Two things follow. The availability your handler really needs is set by the strictest row that applies to you. And none of the rows is about your side: nobody tells you that events are failing *now*, nobody keeps the ones that ran out of attempts, and nobody can show you what your code answered. The retries that do exist are still worth having, and a queue in between takes none of them away. They now cover the hop from the provider into Queuey — the shortest and simplest hop there is — instead of being spent on your handler's bad afternoon. If Queuey cannot accept an event, the provider retries exactly as it would have against you. ## What changes with Queuey in between the path of one event ```text provider ──POST──▶ Queuey ingress ──▶ your queue ──POST──▶ your handler retries on its accepts, stores, retried, held, answers on your terms; own schedule answers 2xx dead-lettered, knows Queuey by an (if it retries) replayable API key ``` | | Provider straight to your handler | With Queuey in between | | --- | --- | --- | | Answering in time | Your handler has to answer within the provider's limit — seconds — before it does any real work. | Queuey returns the 2xx once the event is stored. Your handler gets the time its work needs, and can be slow, mid-deploy or down. | | When the handler fails | The provider's schedule decides: days, hours, or no retry at all. When it runs out the event is gone — or the subscription is. | Retries follow the kind of failure: a handler that is down is held and probed instead of burning attempts, and what cannot be delivered lands in a dead-letter queue. Nothing expires before your retention window does. | | Finding out | A failure email from the provider, a dashboard nobody watches, or a customer — often after the retries have run out. | An issue is raised when deliveries start failing, with an alert by email or Slack — while the events are still waiting in the queue. | | Resending | Per provider, per event, in each provider's own dashboard, inside that provider's window. | Replay one event, or a filtered set in bulk, once the cause is fixed — in one console, for every provider you receive from. | | Finding an event | Each provider's delivery log for what was sent; your own logs for what your code did with it. | Search and filter by status, event type, customer key and time. Every attempt shows what the handler answered and the decision it led to. | | Understanding a failure | Reading logs. | The assistant in the console reads the attempts with you: a diagnosis, a suggested fix, and a resolve plan that runs only when you confirm it. | | Bursts | A sale, a renewal run or a bulk import arrives as a burst, straight at your handler. | The burst lands in the queue and goes out at the pace you set — a rate limit per destination, a set number of requests per time window. What exceeds it waits in the queue. | | More than one consumer | One endpoint per consumer at every provider, each with its own secret and its own failure handling. | Still one endpoint per consumer — each a queue with its own target and auth — but every consumer gets the same failure handling, alerts and replay, in one console. | ## When it is not worth adding — and when it cannot work - Your endpoint already does nothing but verify, write to a durable queue you operate, and return `2xx` — and you are content with the dead-letter handling, replay and visibility you have there. Queuey would replace that plumbing; it would not close a gap. - The events are low-stakes, the handler is simple and idempotent, and the provider's own retries plus an occasional manual resend are enough. - The provider waits for your answer. Stripe's `issuing_authorization.request` is a question, and the response is the decision; a queue answers before your code has run. - The provider verifies the endpoint with a challenge that the endpoint must echo back, as Slack's Events API does. Queuey answers with its own receipt, so that verification fails. **What it costs.** One more hop and one more vendor in the event path. The provider's retries still stand behind Queuey's ingress, exactly as they stood behind your endpoint. ## Setting it up 1. **One queue per provider endpoint.** The queue's ingress URL is what you register at the provider. It answers `202` by default; a provider that insists on exactly `200` gets that from the queue's *Security* tab. 2. **Let Queuey check the sender where it can** — a signing template where Queuey has the provider's scheme, a [source-IP allowlist](https://queuey.ai/docs/how-to/ip-allowlist) where the provider publishes its addresses, an API key where the provider lets you add a header. 3. **Deliver to your handler with an API key** — or a bearer token, OAuth2, or [signed deliveries](https://queuey.ai/docs/how-to/verify-deliveries). That is how your handler knows the caller is Queuey, on the first attempt and on a replay next week. 4. **Optional: keep your handler's own signature check.** Map the provider's signature header through as a [mapped header](https://queuey.ai/docs/how-to/customer-context). Queuey delivers the body byte for byte — as long as the payload format stays *Raw* and the queue has no payload mutations — so a signature over the raw body still matches. | Provider | Check at Queuey's ingress | Header you can forward | Note | | --- | --- | --- | --- | | Stripe | Signing template: the signature and a five-minute window | Stripe-Signature | Carries a timestamp. With Stripe signature recalculation the handler keeps its default tolerance; forward the header instead and widen it — see the Stripe guide. | | Shopify | None yet — keep the check in your handler | X-Shopify-Hmac-Sha256 | No timestamp in the signature; nothing to widen. | | GitHub | Source-IP allowlist — GitHub publishes its hook addresses | X-Hub-Signature-256 | No timestamp in the signature; nothing to widen. | Without a signing template, the queue accepts unsigned requests Anyone who knows the ingress URL can then post to it. A forged event still stops at your handler, whose check of the forwarded signature rejects it, and ends in the dead-letter queue instead of in your system — but it has been stored and counted first. Use the source-IP allowlist where the provider gives you one. The [Stripe guide](https://queuey.ai/docs/how-to/stripe) is the worked example: every console field, the handler code in three languages, and how to move an endpoint that is already in production. ## Where this sits: operational event delivery Provider webhooks are one corner of a larger job: getting an event from the system where it happened to the system that must act on it, and *operating* that delivery — knowing whether it arrived, deciding what to do when it did not, and recovering without losing or duplicating work. The same engine covers the other corners: - webhooks you send to your own customers, where you are the provider in the table above — [quickstart](https://queuey.ai/docs/quickstart), [customer context](https://queuey.ai/docs/how-to/customer-context), [per-customer ordering](https://queuey.ai/docs/how-to/ordering); - event streams you distribute to partners — [Queuey Connect](https://queuey.ai/docs/how-to/connect); - database changes as events — [Supabase database webhooks](https://queuey.ai/docs/how-to/supabase); - producers on networks that come and go — [Queuey Edge](https://queuey.ai/docs/how-to/edge). ## Related - [Stripe webhooks](https://queuey.ai/docs/how-to/stripe) — the worked example, end to end. - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries by failure class, hold-on-timeout and the dead-letter queue. - [Operational runbook](https://queuey.ai/docs/how-to/operations) — diagnose, resume and replay when a handler has been down. # .NET producer: Queuey.Client — publish events from C# Publish events from .NET with the Queuey.Client NuGet package: install it, give QueueyOptions the workspace's signing key, call PublishAsync with an event type, and verify deliveries. Source: https://queuey.ai/docs/reference/dotnet-client --- Reference # .NET producer: Queuey.Client The .NET package a producer publishes with: one client, one `PublishAsync` per event, each request signed for you. It is what `queuey advise` means by "Add Queuey.Client", and it also verifies the deliveries Queuey makes to you. Where the producer has local disk that survives a restart, use [Queuey Edge](https://queuey.ai/docs/how-to/edge) instead. Outside .NET there is no package: publishing is one POST to the ingress, as in the [quickstart](https://queuey.ai/docs/quickstart). ## Install bash ```bash dotnet add package Queuey.Client --prerelease ``` Every version on NuGet is a preview for now, so `--prerelease` is needed: without it, `dotnet add package` finds no version. The package targets `netstandard2.0` and `net8.0`. Package version This page requires Queuey.Client ≥ 0.1.0-preview.9 (newer than 0.1.0-preview.8); its commands need the queuey CLI of the same version. Workspace keys, `--write user-secrets`, `credentials generate`, `UseSettings`, `.env` in Development and `QueueyDeliveryVerifier.FromEnvironment()` are not in 0.1.0-preview.8. ## The key a producer publishes with A producer publishes with a **signing key**: a key id and a secret, `QUEUEY_SIGNING_KEY_ID` and `QUEUEY_SIGNING_SECRET`. The client signs every request with them (the X-Queuey signature headers, see [Signed requests](https://queuey.ai/docs/how-to/signed-requests)), so the secret itself is never sent. Make it with a `queuey login`, straight into the place the app reads it: bash ```bash queuey keys mint --write user-secrets # a .NET project with a UserSecretsId queuey keys mint --write .env # otherwise: a file git ignores ``` - The key reaches every queue in the workspace, also queues made later. `--queue ` makes one for a single queue. - The secret is never printed, and the value in the app is the value Queuey holds. `user-secrets` needs a `UserSecretsId` (`dotnet user-secrets init` adds one); a `.env` in a git repository must be one git ignores. - Without `--write` nothing is minted: the command names the variables the app needs, the `--write` that fits the folder, and the console page where a person makes or looks at the key. `--show-secret` prints the secret once instead, and warns. - In a prod workspace — and a workspace without an environment counts as prod — a login gets `approval_required`: the command exits 5 with the link where a person makes the key, and nothing is minted. - `--type api-key` gives `QUEUEY_API_KEY` instead, a key that can only publish there. Ask for the signature on the workspace, and every queue in it inherits that. In `queuey.deploy.json`: `"workspace": { "ingress": { "authMode": "SignedRequest", "signedRequest": { "template": "queuey" } } }`, which the file `queuey advise --write-files` writes already has. Until the workspace or the queue asks for something, a new queue is open: it takes events from anyone who knows its address. A license-wide API key (`qak_…`) is not the producer's. It belongs to a person, who mints it in the [console](https://app.queuey.ai) (the license's Home page → API keys) to operate Queuey. When `ApiKey` is set, the client publishes with it instead of the signing key, so leave it out of the producer. ## Configure QueueyOptions Program.cs ```csharp using Queuey.Client; // Program.cs. builder.Configuration holds user secrets (in Development) and environment variables. builder.Services.AddQueueyClient(o => { o.TenantPublicId = "ten_…"; o.UseSettings(key => builder.Configuration[key]); // QUEUEY_SIGNING_KEY_ID, QUEUEY_SIGNING_SECRET // No ApiKey: with one set, the client publishes with it instead of the signing key. }); // Then take QueueyClient from DI. ``` `UseSettings` fills each setting that is not set in code from the `QUEUEY_*` name you pass it, from .NET configuration: user secrets in Development, environment variables everywhere. Or read the environment directly: Program.cs ```csharp // The environment, and in Development the signing key from .env as well. builder.Services.AddQueueyClient(o => { o.TenantPublicId = "ten_…"; o.UseEnvironmentVariables(); }); ``` `UseEnvironmentVariables()` reads `QUEUEY_SIGNING_KEY_ID`, `QUEUEY_SIGNING_SECRET`, `QUEUEY_DELIVERY_SECRET`, `QUEUEY_TENANT`, `QUEUEY_LICENSE`, `QUEUEY_API_BASE` and `QUEUEY_INGRESS_BASE` from the environment. In Development (`DOTNET_ENVIRONMENT` or `ASPNETCORE_ENVIRONMENT`) it also reads the signing key, as a pair, and `QUEUEY_DELIVERY_SECRET` from `.env` in the working folder, and nothing else: only from a regular file of your own that git does not track, and the environment always wins. Outside Development it never reads `.env`: in production the values come from environment variables. It reads `QUEUEY_API_KEY` only when the signing key and its secret are not both set, so a key left in the environment never wins over the signing key. Development is not the default `.env` is read only in Development, and .NET loads user secrets only there too. `dotnet run` without a launch profile runs as Production, and then reads neither. Set the environment when you run it: bash ```bash ASPNETCORE_ENVIRONMENT=Development dotnet run # ASP.NET Core DOTNET_ENVIRONMENT=Development dotnet run # a worker or console host ``` or give the project a launch profile that does, which `dotnet run` uses by default: Properties/launchSettings.json ```json { "profiles": { "dev": { "commandName": "Project", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } } ``` The client talks to `https://ingress.queuey.ai` to publish and `https://api.queuey.ai` for the control plane. For a local or self-hosted Queuey, set both hosts: Program.cs ```csharp builder.Services.AddQueueyClient(o => { // A local or self-hosted Queuey. Leave both out for queuey.ai. o.ApiBaseAddress = new Uri("http://localhost:5223"); o.IngressBaseAddress = new Uri("http://localhost:5084"); // …the tenant and the signing key as above }); ``` ## Publish with an event type OrderEvents.cs ```csharp public sealed class OrderEvents(QueueyClient queuey) { public async Task OrderCreated(Order order) { PublishResult result = await queuey.Ingress.PublishAsync("orders", order, new PublishOptions { EventType = "order.created", // X-Queuey-Event-Type GroupKey = order.CustomerId, // X-Queuey-Group-Key: the lane, for per-key ordering IdempotencyKey = order.Id, // Idempotency-Key: a retried call is stored once }); // result.EventId (evt_…) once Queuey has stored it; result.Replayed on a repeat. } } ``` `PublishAsync` sends the payload as JSON (camelCase); the `byte[]` overload sends bytes as they are. It returns once Queuey has stored the event, with its `EventId`, `QueuePublicId`, `ReceivedAtUtc`, `Mode` and `Replayed`. A refused publish throws a `QueueyException` with the HTTP status and Queuey's error code (`ErrorCode`); the codes are in [Headers & status codes](https://queuey.ai/docs/reference/headers). `EventType` is sent as `X-Queuey-Event-Type`, which every queue reads unless its ingress takes the event type from somewhere else, such as a body field. ## Verify what Queuey delivers to you A receiver checks the signature on every delivery, with a secret that is the same value in Queuey and in the receiver. Make it once: bash ```bash queuey credentials generate orders-signing --write user-secrets # or --write .env ``` It stores a random value in Queuey as the credential deliveries are signed with, and writes the same value as `QUEUEY_DELIVERY_SECRET`, never showing it. Point the queue's delivery at it in `queuey.deploy.json`, then apply: `"delivery": { "signing": { "enabled": true, "templateKey": "queuey", "credentialRef": "orders-signing" } }`. Write `templateKey` out: `queuey` is Queuey's own signature, the one `QueueyDeliveryVerifier` checks. The only other value is `stripe`, for a queue whose ingress verifies Stripe and passes Stripe's signature on (see [Stripe webhooks](https://queuey.ai/docs/how-to/stripe)); it signs with the ingress secret and takes no `credentialRef`. A name Queuey already holds is refused; `--replace` makes a new value for every queue that uses the name, so it is a person's decision. Program.cs ```csharp using System.Collections.Concurrent; // QUEUEY_DELIVERY_SECRET from the environment, or in Development from .env. var seenNonces = new ConcurrentDictionary(); // keep them a little over five minutes in a real app var verifier = QueueyDeliveryVerifier.FromEnvironment(new QueueyDeliveryVerifierOptions { NonceAlreadySeen = nonce => !seenNonces.TryAdd(nonce, 0), }); // From user secrets instead: // var verifier = new QueueyDeliveryVerifier( // new QueueyOptions().UseSettings(key => builder.Configuration[key]).DeliverySecret!, // new QueueyDeliveryVerifierOptions { NonceAlreadySeen = nonce => !seenNonces.TryAdd(nonce, 0) }); app.MapPost("/webhooks/queuey", async (HttpRequest request) => { using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer); // the raw bytes, before anything deserializes them var body = buffer.ToArray(); var result = verifier.Verify( request.Method, new Uri($"https://{request.Host}{request.Path}{request.QueryString}"), name => request.Headers[name], body); if (!result.IsValid) return Results.Unauthorized(); await Handle(body, result.EventId); // deduplicate on the event id (below) return Results.Ok(); }); ``` ### What a receiver checks - The signing headers must be there: `X-Queuey-Key-Id`, `X-Queuey-Timestamp`, `X-Queuey-Nonce`, `X-Queuey-Content-SHA256`, and the signature — `X-Queuey-Signatures` for v2, `X-Queuey-Signature` for v1. - The timestamp must be within five minutes of your clock. - The SHA-256 of the **raw** body must equal `X-Queuey-Content-SHA256`. The signature covers that header, not the bytes, so this check is what ties the signature to the body. - **v1**, in `X-Queuey-Signature` as hex: an HMAC-SHA256 with your secret over six lines, `METHOD\nPATH\nQUERY\nTIMESTAMP\nNONCE\nCONTENT_SHA256`. PATH is percent-decoded as ASP.NET Core reads a request path, except `%2F`, which stays encoded: `/hooks/caf%C3%A9/order%2F1/a%20b` is signed as `/hooks/café/order%2F1/a b`. QUERY is normalized: each key and value decoded, sorted, and re-encoded. - **v2**, in `X-Queuey-Signatures` as `v2=` (a comma-separated list, one entry per version): the same HMAC over eight lines, the six of v1 and then `EVENT_ID` (`X-Queuey-Event-Id`) and `IDEMPOTENCY_KEY` (`Idempotency-Key`), each trimmed, and empty when the delivery has none. A delivery carries both versions, over the same timestamp, nonce and body hash. - With a nonce guard (`NonceAlreadySeen` in .NET), a nonce seen before is rejected. Without one, the five-minute window is the only protection against the same delivery sent again. **Deduplicate on the event id or the Idempotency-Key only with v2.** A retry or a redelivery of one event carries the same id, each time with a new nonce, so the id is what to deduplicate on. v2 covers both values: a delivery with another event id or key needs a new valid signature, which takes the secret. v1 covers neither, so with v1 alone whoever captured a delivery can send it again within the five minutes under another event id, and it verifies; a nonce guard narrows that, since the capture's nonce is then spent. The check outside .NET is in [Verify deliveries](https://queuey.ai/docs/how-to/verify-deliveries). ## Streams: PushEventAsync A producer that publishes event streams to integration partners uses `Queuey.Client.Waas`, which adds `IQueueyService` on top of the same client: bash ```bash dotnet add package Queuey.Client.Waas --prerelease ``` Program.cs ```csharp builder.Services.AddQueuey( o => { /* the same QueueyOptions as above */ }, b => b.AddStream()); // IQueueyService from DI. The event type is a required argument; the key is the group key. await queuey.PushEventAsync("order-events", "order.created", order.CustomerId, order); ``` `PushEventAsync` publishes with the same signing key. A new stream asks for an API key unless its workspace already asks for a key or a signature, so set the signature on the workspace before you declare the stream, and the stream inherits it. Declaring the streams (`SyncStreamsAsync`, or `queuey sync`) changes the workspace, so it needs a `queuey login` or a person's API key, not the producer's signing key. See the [WaaS producer API](https://queuey.ai/docs/reference/waas-api) and [Connect](https://queuey.ai/docs/how-to/connect). Queuey Edge signs with the same key `Queuey.Edge` reads the same `QUEUEY_SIGNING_KEY_ID` and `QUEUEY_SIGNING_SECRET` by the same rules, and signs each transfer when it sends it, so a workspace that takes signed requests accepts Edge as it accepts this client. Never put a key in a command's arguments, as a literal in `Program.cs`, or through `/tmp`. The daemon and its environment file are on the [Edge page](https://queuey.ai/docs/how-to/edge). ## Related - [Set up with your AI](https://queuey.ai/docs/agent-setup) — the prompt that installs and wires this up for you. - [Signed requests (HMAC)](https://queuey.ai/docs/how-to/signed-requests) — what the client signs, for a producer outside .NET. - [Queuey Edge](https://queuey.ai/docs/how-to/edge) — the package for producers that must survive being offline. # Stripe webhooks with Queuey — verify the signature, deliver with retries Put a queue in front of your Stripe webhook handler: Queuey verifies the Stripe-Signature at ingress, then delivers with retries, a DLQ and replay. Source: https://queuey.ai/docs/how-to/stripe --- How-to guide # Receive Stripe webhooks through Queuey Stripe expects your endpoint to answer `2xx` quickly, retries for up to three days when it does not, and then stops. With a Queuey queue in between, the event is verified, stored and acknowledged first — and then delivered to your handler with retries, a dead-letter queue and replay. Queuey checks Stripe's signature at the door, so your handler only has to recognise Queuey: an API key on the delivery is what we recommend. A handler that already verifies `Stripe-Signature` can keep doing that too, without a code change: Queuey can recalculate the Stripe signature on each delivery, over the bytes Stripe sent. That part is optional. ## How it fits together the path of one event ```text Stripe ──POST──▶ Queuey ingress ──▶ your queue ──POST──▶ your handler Stripe-Signature verified here, stored, retried, checks your API key over the raw body 5-minute window, dead-lettered, (recommended); may also then 2xx to Stripe replayable verify Stripe-Signature ``` Two hops, each with its own proof. Stripe proves itself to Queuey with its signature, inside the five-minute window Stripe's own libraries use — which means something here, because the request is seconds old. Queuey proves itself to your handler with a credential that is just as valid on a retry three days later. Everything on the Queuey side is configuration: no SDK, no code. ## Do you need anything between Stripe and your handler? Not always. Stripe's webhooks are well built: signed, retried, resendable. That is also why a queue in between costs you nothing in safety — Stripe's retries now cover the hop into Queuey, an ingress built to accept and store. What Stripe cannot do is run your side of the connection, and that is where events get lost: a deploy that returns `500` for an afternoon, a bug that throws on one event type for a week, a handler that does its work before it answers and times out on the first of the month. The honest comparison — the [same reasoning for any provider](https://queuey.ai/docs/how-to/provider-webhooks) is on its own page: | | Stripe straight to your handler | With Queuey in between | | --- | --- | --- | | Answering in time | Your handler has to return 2xx quickly, before it does any real work. Stripe's own advice is to put events on an asynchronous queue first. | Queuey returns the 2xx once the event is stored. Your handler gets the time its work needs, and can be slow, mid-deploy or down. | | When the handler fails | Stripe retries with exponential backoff for up to three days in live mode, then stops. After that it is a manual Resend, event by event — for 15 days in the Dashboard, 30 with the CLI. | Retries follow the kind of failure: a handler that is down is held and probed instead of burning attempts, and what cannot be delivered lands in a dead-letter queue. Nothing expires before your retention window does. | | Finding out | An email from Stripe once an endpoint has been failing for a while, a dashboard nobody watches, or a customer. | An issue is raised when deliveries start failing, with an alert by email or Slack — while the events are still waiting in the queue. | | Resending | Resend in Stripe, event by event, inside Stripe's window. | Replay one event, or a filtered set in bulk, once the cause is fixed — in one console, for every provider you receive from. | | Finding an event | Stripe's event log for what was sent; your own logs for what your code did with it. | Search and filter by status, event type, customer key and time. Every attempt shows what the handler answered and the decision it led to. | | Understanding a failure | Reading logs. | The assistant in the console reads the attempts with you: a diagnosis, a suggested fix, and a resolve plan that runs only when you confirm it. | | Bursts | A renewal run arrives as a burst, straight at your handler. | The burst lands in the queue and goes out at the pace you set — a rate limit per destination, a set number of requests per time window. What exceeds it waits in the queue. | | More than one consumer | One Stripe endpoint per consumer — each with its own secret and its own failure handling, up to 16. | Still one endpoint per consumer — each a queue with its own target and auth — but every consumer gets the same failure handling, alerts and replay, in one console. | | Verifying the signature | In your handler, on your network. | At Queuey's ingress, before anything reaches you — and again in your handler if you want it (step 5). | **When it is not worth adding.** Your endpoint already does nothing but verify, write to a durable queue you operate, and return `2xx` — and you are content with the dead-letter handling, replay and visibility you have there: Queuey would replace that plumbing, not close a gap. Or the events are low-stakes, the handler is simple and idempotent, and three days of Stripe retries plus an occasional manual resend is enough. And the few event types Stripe waits on belong on a direct endpoint either way. **What it costs.** One more hop and one more vendor in your payment event path. Stripe's retries still stand behind Queuey's ingress exactly as they stood behind your endpoint: an event Queuey cannot accept is retried by Stripe, the same as before. ## 1 — Create the queue Create a queue in the console — `stripe` in the examples below. Its ingress URL is `https://ingress.queuey.ai/events/ten_yourTenant/stripe`; the [quickstart](https://queuey.ai/docs/quickstart) shows where to find yours. Use **one queue per Stripe endpoint**: Stripe issues a separate signing secret for every endpoint, and live mode and sandboxes never share one, so `stripe-live` and `stripe-test` are two queues with two secrets. A new queue is *Log-only*: it stores what arrives and delivers nothing. Leave it that way until step 4 is done. ## 2 — Point a Stripe endpoint at it In Stripe, open *Workbench → Webhooks* and create an event destination of type *Webhook endpoint*: Stripe event destination ```yaml Endpoint URL: https://ingress.queuey.ai/events/ten_yourTenant/stripe Events: only the types your handler acts on Signing secret: whsec_… # revealed once the endpoint exists — step 3 needs it ``` Queuey answers `202 Accepted` once the event is durably stored, and Stripe counts any `2xx` as delivered. Rehearse in a sandbox first: `stripe trigger payment_intent.succeeded` sends a real, signed event through the whole path. Already have an endpoint in production? See moving an existing endpoint — it can keep its secret. ## 3 — Verify Stripe at ingress Stripe cannot send custom headers, so an API key is not an option on this hop. It signs every request instead, and Queuey verifies that signature before it accepts the event. On the queue's *Security* tab: Security → How senders authenticate ```yaml Ingress authentication: Signed request (HMAC) Signing template: Stripe (Stripe-Signature) Signing credential: Create Name: stripe-live Key ID: stripe-live # any label — Stripe's scheme has no key id Secret: whsec_… # this endpoint's signing secret ``` The secret is stored encrypted and is never shown again. On every request Queuey then: - computes HMAC-SHA256 over `{t}.{raw body}` with the secret and compares it, in constant time, with every `v1` value in the header — so a request signed while you roll the secret still passes. `v0` and unknown schemes are ignored; - requires the timestamp `t` to be within five minutes of now, the same window Stripe's libraries use; - refuses anything else with `401` and a code — `missing_required_header`, `invalid_signature` or `timestamp_out_of_range`. Stripe sees a failed attempt and retries with a fresh timestamp and signature, so a refused request is not a lost event. Stripe recommends an IP allowlist next to the signature. If you want both, add [Stripe's published webhook addresses](https://docs.stripe.com/ips) to the queue's [source-IP allowlist](https://queuey.ai/docs/how-to/ip-allowlist). The list changes now and then; treat it as a second gate, never the only one. ## 4 — Deliver to your handler with an API key On the queue's *Delivery* tab, set the endpoint and tell Queuey how to authenticate to it. We recommend an API key: one header, one constant-time comparison in your handler, and it is as valid on a replay next week as on the first attempt. Delivery → How we authenticate to you ```yaml Endpoint: https://api.example.com/webhooks/stripe Authentication: API key header Header name: X-Api-Key Credential: Create → a long random value you generate # your handler's secret — not a Queuey API key ``` Any other mode does the same job if it suits your handler better: a bearer token, basic auth, [OAuth2 client credentials](https://queuey.ai/docs/how-to/oauth2-private-key-jwt), or [signed deliveries](https://queuey.ai/docs/how-to/verify-deliveries) — an HMAC signature Queuey makes fresh for every attempt. Then switch the queue from *Log-only* to *Deliver*. This is what reaches your handler: delivery to your handler ```http POST /webhooks/stripe HTTP/1.1 Content-Type: application/json User-Agent: Queuey-Delivery/1.0 (+https://queuey.ai/abuse) X-Api-Key: 9f2c…e1 # step 4 — how your handler knows it is Queuey Idempotency-Key: queuey:evt_nd9qgy1sjPIW X-Queuey-Event-Id: evt_nd9qgy1sjPIW X-Queuey-Path: que_yourStripeQueue Stripe-Signature: t=1758350000,v1=5257a869e7ec… # step 5 — only if you turn it on { "id": "evt_1QxK2mLkdIwHu7ix", "object": "event", "type": "invoice.paid", "data": { … } } ``` The body is the bytes Stripe sent — not parsed, not re-serialized. The `Idempotency-Key` and the `X-Queuey-*` headers are Queuey's own and stay the same across retries of one event. Your handler checks the key and reads the event; the Stripe signature has already been checked: ```typescript import crypto from "node:crypto"; import express from "express"; import type Stripe from "stripe"; const deliveryKey = process.env.QUEUEY_DELIVERY_KEY!; // the value you stored in step 4 function isQueuey(presented = ""): boolean { const a = Buffer.from(presented, "utf8"); const b = Buffer.from(deliveryKey, "utf8"); return a.length === b.length && crypto.timingSafeEqual(a, b); } const app = express(); app.post("/webhooks/stripe", express.json(), (req, res) => { // 401 tells Queuey the credential is wrong: it holds the queue and asks // for action, instead of dead-lettering events that did nothing wrong. if (!isQueuey(req.get("X-Api-Key"))) return res.sendStatus(401); const event = req.body as Stripe.Event; // Stripe's signature was verified by Queuey at ingress // Your own code from here. Deduplicate on event.id, as Stripe advises. res.sendStatus(200); }); ``` ```python import hmac import os from flask import Flask, request app = Flask(__name__) delivery_key = os.environ["QUEUEY_DELIVERY_KEY"].encode() # the value you stored in step 4 @app.post("/webhooks/stripe") def stripe_webhook(): # 401 tells Queuey the credential is wrong: it holds the queue and asks # for action, instead of dead-lettering events that did nothing wrong. presented = request.headers.get("X-Api-Key", "").encode() if not hmac.compare_digest(presented, delivery_key): return "", 401 event = request.get_json() # Stripe's signature was verified by Queuey at ingress # Your own code from here. Deduplicate on event["id"], as Stripe advises. return "", 200 ``` ```csharp using System.Security.Cryptography; using System.Text; using Stripe; app.MapPost("/webhooks/stripe", async (HttpRequest request, IConfiguration config) => { // 401 tells Queuey the credential is wrong: it holds the queue and asks // for action, instead of dead-lettering events that did nothing wrong. var presented = Encoding.UTF8.GetBytes(request.Headers["X-Api-Key"].ToString()); var expected = Encoding.UTF8.GetBytes(config["Queuey:DeliveryKey"]!); // the value you stored in step 4 if (!CryptographicOperations.FixedTimeEquals(presented, expected)) return Results.Unauthorized(); using var reader = new StreamReader(request.Body); var json = await reader.ReadToEndAsync(); var stripeEvent = EventUtility.ParseEvent(json); // Stripe's signature was verified by Queuey at ingress // Your own code from here. Deduplicate on stripeEvent.Id, as Stripe advises. return Results.Ok(); }); ``` ## 5 — Optional: keep verifying Stripe's signature in your handler, unchanged Nothing requires this. It is for a handler that already calls `constructEvent` and should keep doing so. With **Stripe signature recalculation** on the queue, it keeps calling `constructEvent` exactly as it does today: the Stripe endpoint's `whsec_…` secret, no code change. Queuey signs Stripe's unchanged bytes again with a fresh timestamp on each attempt — first delivery, retries and replays — so your handler's constructEvent keeps working with the default tolerance. Only events that arrive through this queue's Stripe-verified ingress after you turn it on are signed. Turn it on under *Delivery → Request signing*: tick *Recalculate the Stripe signature on every delivery*. Queuey signs with the secret this queue's Stripe ingress verifies with — the signing secret of the Stripe endpoint from step 2 — and with no other, so a handler that used to receive from that endpoint directly already has it (see moving an existing endpoint); a handler that verified another endpoint needs this one's secret. The checkbox is there only when the queue's ingress verifies Stripe as in step 3, and only on the queue itself, not as a workspace default. It takes the place of *Queuey HMAC* rather than adding to it: a queue signs one way. A handler that admits only Stripe's IP addresses will reject every delivery Recalculation changes the signature, not the sender. Deliveries come from Queuey's own IP addresses, with the User-Agent `Queuey-Delivery/1.0 (+https://queuey.ai/abuse)` and the `X-Queuey-Event-Id` and `X-Queuey-Path` headers. If your handler, or a firewall in front of it, checks for Stripe's published addresses, remove that check for this handler. Queuey does not publish addresses to put in its place; the API key from step 4 is what identifies it. ### What Queuey signs, and what it refuses Queuey signs an event as Stripe only if it arrived through the queue's ingress after recalculation started, and only with the secret the ingress verified it with. While recalculation is on, the ingress can't be switched away from verifying Stripe, and giving it another secret starts recalculation again, so every event Queuey signs passed the check from step 3 with that secret. Recalculation starts 30 seconds after you save — the longest the ingress can take to pick up a changed setting — and the queue shows when. On every delivery attempt Queuey then signs the stored bytes: `t` is the time of this attempt, and `v1` is HMAC-SHA256 over `{t}.{raw body}`. Stripe does the same on its own retries and when you use Resend in its Dashboard: a fresh `t` every time, while the event's own time stays in the body as `created`. So a retry after an outage, a queue that was held and a replay from the dead-letter queue all reach your handler with a signature seconds old. It refuses three things: - **Events from before recalculation started.** An event that arrived earlier — for example while the queue's ingress was still open — is not signed as Stripe: it goes to the dead-letter queue with failure class `OriginNotVerified`, and the rest of the queue keeps delivering. Resend in Stripe brings it back as a new event. A test delivery typed in the console is refused the same way, with the reason in its result, because Stripe never sent it. To test the path, send a real event: `stripe trigger` in a Stripe sandbox, or Resend in Stripe. - **Bodies Queuey has changed.** A queue that recalculates the Stripe signature cannot have a Transform Map or payload patches — its own or inherited from the workspace — and its payload format must be **Raw (as received)**. The combination is refused when you save. - **A second `Stripe-Signature`.** The header belongs to the signature, so it cannot also be a static header, a mapped header or the GroupKey header. That is also why recalculating the signature and forwarding Stripe's own one, below, cannot share a queue. **A receiver you do not run.** A service that accepts Stripe webhooks verifies them with the signing secret you give it. Give it the secret of the Stripe endpoint from step 2: Queuey signs with that secret and no other, so a service that insists on a secret of its own can't sit behind this queue — give it a Stripe endpoint of its own instead. **Your handler still deduplicates on Stripe's event id.** A valid signature does not mean a first delivery: Queuey's retries and replays deliver the same event again, each signed afresh, just as Stripe's own retries do — and Stripe already asks you to skip the ids you have processed. Keep the API key from step 4 as well, as defence in depth: the signature vouches for the bytes, the key for the caller. ### If you want proof independent of Queuey: forward Stripe's own signature With recalculation, the `v1` your handler checks was made by Queuey, with your secret, after Queuey checked Stripe's. If the handler should check a signature Stripe made itself, forward Stripe's header instead. The two cannot be combined on one queue — `Stripe-Signature` cannot be both mapped and recalculated — so leave recalculation off, leave the payload format on **Raw (as received)** — the default — and add one *mapped header* under *Delivery → Payload & ordering*: Delivery → Payload & ordering → Mapped headers ```text Sends header ← Read from the incoming request Stripe-Signature ← Header: Stripe-Signature ``` Queuey reads the header off Stripe's request when the event is accepted, stores it with the event, and sends that stored value on every delivery attempt. Leave *required* off: with the Stripe template on, a request without the header never gets this far. Because the body is forwarded byte for byte, the signature still matches. `Content-Type` arrives without Stripe's `charset` parameter, which does not matter: the signature covers the timestamp and the body only. Use it together with the API key from step 4, not instead of it. No payload mutations on a queue that forwards the signature A Transform Map or a JSON Patch changes the bytes, and a signature over the original bytes cannot match a changed body. Queuey still forwards the header, and your handler rejects the event. If you need to reshape Stripe events, rely on step 4 alone. **The price is the tolerance.** Stripe's libraries reject a signature whose timestamp is more than five minutes old, and a forwarded signature keeps the timestamp Stripe gave it when it sent the event. A first delivery reaches your handler within moments, well inside the window. A retry after your handler was down, a queue that was held, or a replay from the dead-letter queue does not. With the default tolerance that delivery fails verification and your handler answers `400`. Queuey reads a `400` as the receiver rejecting that event: it is not retried, it goes to the dead-letter queue — and replaying it cannot help, because a forwarded signature only gets older. The events Queuey exists to save would be exactly the ones your handler refuses. Recalculation removes that problem; forwarding has to work around it. So pass a tolerance that covers the longest an event may wait in Queuey. Your plan's retention window is the upper bound: ```typescript const secret = process.env.STRIPE_WEBHOOK_SECRET!; // the same whsec_… Queuey verifies with // The longest an event may wait in Queuey and still be delivered — a retry // after an outage, or a replay from the dead-letter queue. In seconds. const TOLERANCE = 7 * 24 * 60 * 60; app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => { if (!isQueuey(req.get("X-Api-Key"))) return res.sendStatus(401); // step 4 stays in front let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( req.body, // the raw bytes, never a parsed object req.get("Stripe-Signature")!, // forwarded by Queuey exactly as Stripe sent it secret, TOLERANCE, // the library default is 300 ); } catch { return res.status(400).send("signature verification failed"); } // Your own code from here. Deduplicate on event.id. res.sendStatus(200); }); ``` ```python # The longest an event may wait in Queuey and still be delivered — a retry # after an outage, or a replay from the dead-letter queue. In seconds. TOLERANCE = 7 * 24 * 60 * 60 @app.post("/webhooks/stripe") def stripe_webhook(): presented = request.headers.get("X-Api-Key", "").encode() if not hmac.compare_digest(presented, delivery_key): # step 4 stays in front return "", 401 try: event = stripe.Webhook.construct_event( request.data, # the raw bytes request.headers.get("Stripe-Signature"), # forwarded by Queuey as Stripe sent it os.environ["STRIPE_WEBHOOK_SECRET"], # the same whsec_… Queuey verifies with tolerance=TOLERANCE, # the library default is 300 ) except (ValueError, stripe.SignatureVerificationError): return "signature verification failed", 400 # Your own code from here. Deduplicate on event["id"]. return "", 200 ``` ```csharp // The longest an event may wait in Queuey and still be delivered — a retry // after an outage, or a replay from the dead-letter queue. In seconds. const long Tolerance = 7 * 24 * 60 * 60; // …after the API-key check from step 4, with the body read into json: Event stripeEvent; try { stripeEvent = EventUtility.ConstructEvent( json, // the body exactly as received request.Headers["Stripe-Signature"], // forwarded by Queuey exactly as Stripe sent it config["Stripe:WebhookSecret"], // the same whsec_… Queuey verifies with tolerance: Tolerance); // the library default is 300 } catch (StripeException) { return Results.BadRequest("signature verification failed"); } ``` Use a real number rather than `0`. Stripe's documentation says `0` switches the check off, which is true of most of its libraries — but Stripe.net compares against the value as it is, and would reject nearly every event. What a wide tolerance gives up, and what covers it The five-minute window is replay protection, and it has moved to where it works: Queuey enforces it at ingress, seconds after Stripe signed. At your handler the signature still proves that Stripe produced these exact bytes; what a wide tolerance gives up is freshness. Two things cover that. The API key from step 4 means only Queuey can reach the handler at all. And Stripe already asks you to **record the event ids you have processed and skip the ones you have seen** — which makes a replayed request a no-op. ## Moving an existing endpoint A Stripe signing secret belongs to the endpoint, not to its URL. You can therefore put Queuey in front of a handler that is already in production without issuing a new secret, without a gap — and, with Stripe signature recalculation, without changing the handler: 1. Set up the queue as in steps 1 and 3, with the secret your handler already uses. On the *Delivery* tab, give it the handler's current URL, tick *Recalculate the Stripe signature on every delivery* with the same secret (step 5), and set the API key from step 4 — a handler that does not check the key yet ignores the header. Switch the queue to *Deliver*. 2. In Stripe, edit the endpoint's URL to the queue's ingress URL. The secret stays the same; from the next event on, the path runs through Queuey, and your handler verifies each delivery the way it verified Stripe's. Rolling back is the same edit in reverse. 3. When Stripe's event deliveries show the new URL, add the API-key check to the handler when it suits you — defence in depth, not a condition for the move. One thing can still stop deliveries: a handler or firewall that admits only Stripe's IP addresses. Change that before step 2 (step 5 says why). Without recalculation, the handler has to change first. To rely on the API key alone, make it accept both callers — a request with the key is Queuey, anything else goes through the Stripe check you already have — and deploy that before step 2. To forward the signature instead, widen its tolerance before step 2. Either way, avoid running two live Stripe endpoints side by side — one direct, one through Queuey — unless the queue stays in *Log-only*: your handler would get every event twice, signed with two different secrets. Keep the events Stripe waits on at a direct endpoint For a few event types Stripe acts on your endpoint's response, and a queue answers before your code has run. `issuing_authorization.request` is a synchronous question — the response *is* the decision — and cannot go through a queue at all. Stripe also holds automatic invoice finalization until `invoice.created` is acknowledged, and Checkout holds its redirect for up to ten seconds waiting on `checkout.session.completed`; through Queuey both are acknowledged at once. If you rely on any of these, register a second Stripe endpoint for those types that points straight at your handler, and send everything else through the queue. ## What changes, and what does not - **Stripe's delivery view stops at Queuey.** An event is *Delivered* in Stripe when Queuey has stored it. Whether your handler has processed it is in the Queuey console: every attempt, what the handler answered, and why Queuey retried, held or stopped ([operational runbook](https://queuey.ai/docs/how-to/operations)). - **Order.** Stripe does not guarantee that events arrive in the order they happened, and Queuey cannot restore an order Stripe never had. Keep the handler independent of order, as Stripe advises. Per-key [lanes](https://queuey.ai/docs/how-to/ordering) need a top-level body field, and Stripe keeps the customer inside `data.object`. Connect is the exception: events from connected accounts carry a top-level `account`, which works as a [GroupKey](https://queuey.ai/docs/how-to/customer-context) — one lane per connected account. - **Duplicates.** Stripe can deliver the same event more than once and sends no `Idempotency-Key`, so Queuey has nothing to deduplicate on at ingress: two deliveries from Stripe become two events. Deduplicate on Stripe's `id` in the handler. Queuey's own retries of one event carry the same body and the same `Idempotency-Key`. - **Event types in the console.** Set event typing on the queue's *Events* tab to the JSON body field `type`, and every event is labelled `invoice.paid`, `customer.subscription.updated` and so on — filterable, without opening a payload. - **Rolling the secret.** Stripe signs with the old and the new secret for up to 24 hours. Within that window, create a new signing credential and select it on the queue's ingress. If the queue recalculates the Stripe signature, that starts it again with the new secret 30 seconds later: an event that arrived before is never signed with the new secret — it goes to the dead-letter queue, and you resend it from Stripe. Give the handler the new secret at the same time. If you forward the signature instead, update the handler too, and deliver or replay the events accepted before the roll before the handler forgets the old secret. - **Thin events** are signed the same way, with the same header. Give a thin-event destination its own queue: it is a separate endpoint with its own secret. ## Troubleshooting | What you see | Why | Fix | | --- | --- | --- | | Stripe shows 401 from Queuey, code invalid_signature | The signing credential holds another endpoint's secret. Every Stripe endpoint has its own, and live mode and sandboxes never share one. | Reveal the signing secret on this endpoint in Stripe and store that one. | | 401 missing_required_header when you test with curl | The request carries no Stripe-Signature. That is the gate working. | Send a real event: stripe trigger payment_intent.succeeded, or create the object in a sandbox. | | The queue is held and asks for action after a 401 or 403 from your handler | The API key Queuey sends is not the one your handler expects. Queuey treats that as a broken target, not a bad event: nothing is dead-lettered, and nothing is delivered until it is fixed. | Correct the credential or the header name under Delivery, then resume the queue. | | Recalculation: an event is dead-lettered with failure class OriginNotVerified — “Not signed as Stripe: …” | The event arrived before recalculation started — it starts 30 seconds after you save the delivery settings — for example while the queue's ingress was still open. Queuey can't vouch that Stripe sent it, so it doesn't sign it as Stripe. (A test delivery typed in the console is refused the same way, with the reason in its result; it never becomes an event.) | Nothing on the queue needs fixing, and the other events keep delivering. For a real Stripe event, use Resend in Stripe: it arrives as a new event with a current signature. To test the path, send a real event with stripe trigger. | | Recalculation: the queue is held and asks for action | The attempt's failure class is SignatureRecalculationFailed, and its error names the cause: the queue's ingress no longer verifies Stripe, a Transform Map or payload patch — the queue's own or inherited from the workspace — now changes the body, the ingress secret can't be used (revoked or deleted), or the ingress got another secret in a change that didn't save the queue. Queuey held the delivery before sending, and the events wait in the queue. | Put the ingress back on Signed request with the Stripe template, remove the transform or patch — at the workspace, if that is where it lives — replace the secret, or save the queue's delivery settings to start recalculation again with the new secret. Then Verify & resume. | | Recalculation: the handler answers 400 — “No signatures found matching the expected signature” | The handler's secret is not the one Queuey signs with — the secret this queue's ingress verifies with: it belongs to another Stripe endpoint, or the ingress secret was rolled and the handler still has the old one. | Give both sides the same secret, then replay the events from the dead-letter queue. Every attempt is signed afresh, so a replay verifies. | | Forwarded signature: “Timestamp outside the tolerance zone” / “outside of the allowed tolerance” | The delivery came more than five minutes after Stripe signed it — a retry, a held queue or a replay — and the handler still uses the library default. | Turn on Stripe signature recalculation for the queue (and remove the mapped header), or pass a tolerance that covers your queue, as in step 5. Then replay the event from the dead-letter queue. | | Forwarded signature: “No signatures found matching the expected signature” | The bytes or the secret differ: a Transform Map or JSON Patch on the queue, a framework that parsed the body before you verified it, or a handler that holds another endpoint's secret. | Keep the queue on Raw with no payload mutations, verify the raw body, and use this endpoint's secret on both sides. | | Forwarded signature: the handler gets no Stripe-Signature header | The event was accepted before the mapped header was saved. The value is read once, at accept, and changing the mapping does not rewrite events already queued. | Save the mapping before Stripe starts sending. For the events in between, use Resend in Stripe. | ## Related - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries, the dead-letter queue, and why a rejected event is not retried. - [Customer context](https://queuey.ai/docs/how-to/customer-context) — how mapped headers are resolved, stored and replayed. - [Headers & status codes](https://queuey.ai/docs/reference/headers) — what Queuey sends, and which names can never be forwarded. # Should you build webhook retries yourself? Test the tail first The first retry loop is small. What grows around it is not. Five receiver failures, run against Queuey's sandbox in under a minute, with no signup. Source: https://queuey.ai/docs/how-to/build-it-yourself --- How-to guide # Should you build webhook retries yourself? Test the tail first The first version of webhook delivery is small: read an event, POST it, retry if it fails. If that is all your flow needs, write it — this page says when it is. What decides the question is what grows around that loop once it runs in production, and who operates it afterwards. Below is that list, and five of its failures you can run against Queuey's sandbox in under a minute, with no signup. ## What grows around the first loop - durable storage, queues and workers, with retry schedules, backoff and Retry-After - running the queue and its workers apart from your API's servers and database, so a burst of events does not take your API down with them - per-destination concurrency and per-customer rate limits, so one slow receiver does not stall the rest - signing and secret rotation, idempotency, per-key ordering - failure classification: a 503 waits, a 429 slows down, a 401/403 needs a person, a 422 will never succeed unchanged — a generic retry scheduler treats them all the same - holding and probing an unhealthy destination instead of a retry storm into it - a dead-letter queue, and replay that is safe to run on a filtered set - a delivery log, alerts, and a way for support to answer "was it delivered, and why not?" without an engineer reading logs None of these is hard alone. The cost is keeping all of them correct together, on call, as the product changes. ## Five receiver failures, side by side A plain retry loop — retry anything that is not a `2xx`, a fixed number of times — against what Queuey decided when this sequence ran. The last column is what the sandbox reports for each attempt. | The receiver answers | A plain retry loop | Queuey | Decision per attempt | | --- | --- | --- | --- | | 503 twice, then 200 | Retries and gets there. A plain loop handles this one. | Retries with backoff and delivers on the third attempt. | retry_transient_target_unavailable → retry_transient_target_unavailable → success | | 429 with Retry-After: 5, then 200 | Retries on its own schedule, usually too early, and spends the receiver's rate limit doing it. | Waits the five seconds the receiver asked for, then delivers. | retry_after_target_hint → success | | 422 on every attempt | Sends the same payload until the attempts run out. It will never be accepted unchanged. | No retry. Dead-lettered on the first answer, as a payload problem. | no_retry_bad_payload | | 401 on every attempt | Keeps retrying against a credential that no longer works. | No retry. The sandbox dead-letters it, because a trial queue has no target to hold. In production nothing is dead-lettered: the event is held, and the queue stays stopped until a person fixes the credential and runs Verify & resume. | no_retry_authentication_failed | | 503 for 30 seconds, then 200 | Depends on the budget: gives up before the receiver is back, or retries without knowing it is down. | Keeps retrying on a paced schedule and delivers once the receiver answers. The events are delivered in order, so nothing overtakes it. | retry_transient_target_unavailable → … → success | ## Run it yourself The sandbox runs the real delivery engine against an internal receiver; it never calls your endpoints or anyone else's. It needs `curl` and `jq`, and the report is complete about 45 seconds after step 2. the tail, in the sandbox ```bash JAR=$(mktemp) # 1. An isolated run: no signup, an internal receiver, deleted after ~2 hours RUN=$(curl -s -c "$JAR" -b "$JAR" -X POST https://api.queuey.ai/trial/runs \ -H "Content-Type: application/json" -d '{}' | jq -r .publicId) # 2. Five events, each meeting a different receiver failure curl -s -c "$JAR" -b "$JAR" -X POST https://api.queuey.ai/trial/runs/$RUN/fire-sequence \ -H "Content-Type: application/json" -d '{"steps":[ {"label":"503-then-recovers","outcome":1,"failCode":503,"failCount":2}, {"label":"429-with-retry-after","outcome":1,"failCode":429,"failCount":1,"rateLimitRetryAfterSec":5}, {"label":"422-bad-payload","outcome":2,"failCode":422}, {"label":"401-access-revoked","outcome":2,"failCode":401}, {"label":"down-for-30s","outcome":5,"failCode":503,"failForSeconds":30} ]}' | jq .stepsAccepted # 5 # 3. About 45 seconds later: each event's final status (2 delivered, 6 dead-lettered) # and the decision behind every attempt, in the order the steps were sent curl -s -b "$JAR" https://api.queuey.ai/trial/runs/$RUN \ | jq -c '.reports[] | [.finalStatus, (.timeline[] | "\(.responseCode // "timeout") \(.decisionReason)")]' ``` Each line of the output is one event: its final status — `2` delivered, `6` dead-lettered — and the answer and decision behind every attempt. `GET /trial/runs/$RUN/events/{eventId}` shows the full record for one of them. What the sandbox does not show Signing, per-customer ordering and a slow receiver sharing a queue with healthy ones need more than one receiver, and the sandbox has one. The [signing](https://queuey.ai/docs/how-to/signed-requests) and [ordering](https://queuey.ai/docs/how-to/ordering) guides cover those. ## When building it is the right call Building is still the right call when delivery is the product's own differentiator, the protocol or guarantees are unusual, regulation rules out a managed service, or the team already runs mature shared delivery infrastructure. So is keeping the small loop, when there are few events, one receiver you control, and a provider whose own retries are enough. **What Queuey costs.** Free €0/month (5,000 tokens / month), Team €49/month (100k tokens / month included), Growth €249/month (1M tokens / month included), excl. VAT. A token is one started 16 KB of payload; retries are free. Compare it with the engineering and on-call time the list above takes, not with the cost of the first version. ## Related - [Queuey vs building your own webhook delivery](https://queuey.ai/articles/queuey-vs-building-webhook-delivery/) — the comparison in full, with the operator questions. - [Reliable delivery](https://queuey.ai/docs/how-to/reliable-delivery) — retries by failure class and the dead-letter queue. - [Provider webhooks](https://queuey.ai/docs/how-to/provider-webhooks) — when a queue in front of your handler is worth it, and when not. # Connect an AI client over MCP — Claude, ChatGPT, Codex Connect Claude Code, Claude, ChatGPT or Codex to Queuey's MCP server to operate queues from an AI client: sign in or use an API key, what a connection can do, whoami, and troubleshooting. Source: https://queuey.ai/docs/how-to/mcp --- How-to guide # Connect an AI client over MCP Queuey's MCP server, `https://api.queuey.ai/mcp`, lets an AI client operate Queuey with you: read workspaces, queues, events and issues, troubleshoot a delivery, and change a queue's setup. It is for operating from the cloud, in clients such as claude.ai and ChatGPT that cannot run a command line. A coding agent working in a repository uses the `queuey` CLI instead — see [Set up with your AI](https://queuey.ai/docs/agent-setup). ## Two ways in - **Sign in** from Claude Code, Claude, ChatGPT or Codex. You approve the client in the Queuey console, and there is no key to copy. These four are the clients Queuey accepts a sign-in from. - **An API key** (`qak_…`) in `X-Api-Key`, or as `Authorization: Bearer qak_…`, from any MCP client that can send a header. Either way, the client acts in one license, with what the sign-in or the key allows. ## Claude Code bash ```bash claude mcp add --transport http queuey https://api.queuey.ai/mcp claude mcp login queuey # or, in Claude Code: /mcp → queuey → Authenticate ``` With a key, which works today: bash ```bash claude mcp add --transport http queuey https://api.queuey.ai/mcp \ --header 'X-Api-Key: ${QUEUEY_API_KEY}' ``` Keep the single quotes. With them, Claude Code reads `QUEUEY_API_KEY` each time it connects. In double quotes the shell expands it once, when you add the server, and a variable that is not set stores an empty key (`api_key_empty`). Add `--scope user` to have Queuey in every project. ## Claude (claude.ai, Desktop, mobile) 1. In claude.ai, open *Customize → Connectors → Add custom connector*. 2. Name it Queuey, and enter `https://api.queuey.ai/mcp` as the remote MCP server URL. 3. Choose to sign in, and for the OAuth client *Use Claude's published identity*. 4. Connect, and approve Queuey in the console (below). The connector follows your account to Claude Desktop and the mobile apps. On a Team or Enterprise plan an Owner adds it, under *Organization settings → Connectors → Add → Custom → Web*, and each member connects under *Customize → Connectors*. Without sign-in, the connector can carry a key under *Request headers* (`X-Api-Key`). Claude keeps it on its servers, so give it a key with only the access the connector needs. ## ChatGPT Add Queuey as a custom MCP server: on [chatgpt.com/plugins](https://chatgpt.com/plugins), choose + and *Add custom MCP server*, enter `https://api.queuey.ai/mcp`, choose OAuth, and sign in. ChatGPT has called these connectors and apps, and the place to add one has moved with the name; your workspace's admin may have to allow custom servers first. ## Codex CLI bash ```bash codex mcp add queuey --url https://api.queuey.ai/mcp codex mcp login queuey ``` With a key, which works today: ~/.codex/config.toml ```toml # ~/.codex/config.toml — Codex reads the key from QUEUEY_API_KEY when it connects [mcp_servers.queuey] url = "https://api.queuey.ai/mcp" env_http_headers = { "X-Api-Key" = "QUEUEY_API_KEY" } ``` ## What happens when you sign in 1. The client opens your browser on the Queuey console. The page names the app, its site (claude.ai or chatgpt.com) and where it sends you back. For Claude Code and Codex that is this machine: allow it only if you just started the sign-in there. 2. You choose the license, and what the client may do: *Set up and operate* (`operate`, chosen first) or *Read only* (`read`). 3. Allow, and the browser goes back to the client, which is connected. The connection acts as you, within your role in that license. It is listed under *Connected apps* in the [console](https://app.queuey.ai/console)'s account menu, with the client, the license and the access; disconnecting it there ends it at once. ## What a connection can do - **Read only:** 19 tools — workspaces, queues, events, deliveries and issues. It changes nothing. - **Set up and operate:** 46 tools — the read tools, the `prepare_*` changes and the operations that run, follow, cancel and undo them, the issue tools, a test event through a queue's flow, and a request for a person to paste a secret. - **Never:** delete a queue or a workspace, or create a key. Deleting is done in the console, and no tool on `/mcp` mints keys. - **In production** a change that needs a person is not made: the answer carries a link to your approval inbox, where a person approves it. The counts are for an Owner or Admin. A narrower role gives a shorter list. A key gets the tools its permissions reach. ## whoami Ask the client to call `whoami`. It says who the caller is (a key, or a connection as «person via client»), the license it acts in, its access and permissions, how many tools it has, each tool it does not have and why, and how to get more: connect again with `operate`, or get a role from an Owner. Call it when a tool you expected is missing, or an id answers not found. It never shows a secret or an e-mail address. ## Other clients, and API keys Any MCP client that speaks streamable HTTP and can send a header connects with a key. A person mints the key in the [console](https://app.queuey.ai) (the license's Home page → API keys), and it acts in its license with its permissions. A license-wide key is the person's own: give a client a key with only the access it needs. Keep the key in an environment variable, and let the client read it when it connects: .mcp.json ```json { "mcpServers": { "queuey": { "type": "http", "url": "https://api.queuey.ai/mcp", "headers": { "X-Api-Key": "${QUEUEY_API_KEY}" } } } } ``` ## Troubleshooting | Answer | What it means | | --- | --- | | 401 authentication_required | The request sent neither a sign-in nor a key. Sign in from the client, or add the key header. | | 401 api_key_empty | X-Api-Key or Authorization: Bearer was sent, but empty. Almost always a shell that expanded a variable that was not set when the server was added. Write the header in single quotes, 'X-Api-Key: ${QUEUEY_API_KEY}', so the client reads the variable each time it connects, or sign in. | | 401 api_key_required | A key was sent that does not work on /mcp: revoked, mistyped, or not a qak_ key. | | 401 invalid_token | The client's token is expired, revoked (disconnected under Connected apps) or not one for /mcp — a token from queuey login works on the REST API only. The client refreshes it; if that fails, sign in again. | | queue_not_found, workspace_not_found | The id is not one you can read in the license the answer names. An id in another license is not found from here: call whoami to see your license, and connect again choosing the other one. | | invalid_request at sign-in | The description names the parameter Queuey did not accept, such as resource or code_challenge. The client is not one of the four above, or a version that does not send it: update the client, or connect with a key. | | invalid_argument | A tool call left out an argument or gave one the tool cannot use. The message names the argument. | # Reference: Queuey WaaS producer API (streams, packages, partners) Reference for the Queuey WaaS producer endpoints: declarative stream apply, integration invites, group-key activations, and packages/grants for distributing event streams to partners. Source: https://queuey.ai/docs/reference/waas-api --- Reference # WaaS producer API The producer-side endpoints for distributing your event streams to integration partners: declarative stream apply, integration invites, group-key activations, packages, and access control (block / un-block). Auth & conventions The `/waas/*` endpoints take a signed-in console session (a Microsoft Entra **bearer token**) with the Owner or Admin role on the producer workspace, or an API key whose profile has the Connect permissions: a *Build (set up and publish)* key from the *API keys* tab on the license's Home page, scoped to the producer workspace, with the license id in `X-License-PublicId`. A *Limit to ingress* or *Full access* key has no Connect permissions, so the writes refuse it. Request and response bodies are **camelCase** JSON. Public ids are prefixed: `ten_` (workspace — the API calls workspaces `tenants`), `cat_` (catalog stream), `pkg_` (package), `int_` (integration), `act_` (activation). Who pays for what The producer pays for distribution: each event's first delivery to an integration is metered on the producer's tokens, one token per started 16 KB. The integration owns its receiving workspace on its own account and can troubleshoot, skip and replay there itself. A replay costs the event's tokens on the integration's account; inspecting and skipping are free. A free account includes tokens for this, so an integration never needs a paid plan just to receive. ## Streams `PUT /waas/streams` is the declarative deploy/CLI target — safe to call repeatedly. One call promotes the workspace to a producer, makes the queue Public, configures context-header extraction, and upserts the catalog entry. A queue it creates requires an API key to publish, unless the workspace already demands a key or a signed request; an existing queue keeps its authentication. stream apply ```json PUT /waas/streams { "producerTenantPublicId": "ten_yourTenant", "name": "orders", "description": "Order lifecycle events", "eventTypes": ["order.created", "order.shipped"], "payloadSchema": "{ ... }", // opaque, stored verbatim "isPublic": true } 200 OK { "publicId": "cat_...", "key": "orders", "status": "Published" } ``` | Endpoint | What it does | | --- | --- | | PUT /waas/streams | Declarative, idempotent stream apply. Converges a stream (a Public queue named after it + context-header extraction + a catalog entry) from desired state. A queue it creates requires an API key to publish, unless the workspace already demands a key or a signed request. Re-apply is a no-op. isPublic:true → Published, else Deprecated. | | POST /waas/enable-producer | Mark a workspace as a producer (ProducerSystem). | | GET /waas/producer/{tenant}/catalog | List the producer's published streams (catalog entries). | | GET /waas/producer/{tenant}/metrics/series?history=1h|24h|7d | Aggregate distributed-traffic series for the producer. | ## Integrations & activations An **activation** `(producer, groupKey, integration)` is the routing gate: events only reach an integration when an active activation exists for the event's group key — even if the integration is subscribed. An invited integration accepts onto an account it owns or administers, or a new free one, and its receiving workspace lives there — not under the producer. | Endpoint | What it does | | --- | --- | | POST /waas/integrations | Invite an integration (recipient). Body: { producerTenantPublicId, email } → { publicId:"int_…", invitedEmail, status:"Pending" }. | | POST /waas/activations | Activate a group key for an integration — the routing gate. Body: { producerTenantPublicId, groupKey, integrationPublicId } → { publicId:"act_…", groupKey, status }. Idempotent. | | DELETE /waas/activations | Revoke a group key. Same body → 204. Idempotent (never 404). | | GET /waas/producer/{tenant}/integrations | List the producer's integrations. | ## Packages Access is **default-open**: a linked partner can see and subscribe to every* published* stream, whether or not it's in a package. Packages are a grouping and an opt-out lever — they let you block a whole category for one integration in a single call. | Endpoint | What it does | | --- | --- | | POST /waas/producer/{tenant}/packages | Create a package. Body: { name, description? } → { publicId:"pkg_…", key, name, status:"Active" } (key is a slug). | | PUT /waas/producer/{tenant}/packages/{pkg} | Rename / re-describe a package. Body: { name, description? }. | | POST /waas/producer/{tenant}/packages/{pkg}/streams | Add a stream to a package. Body: { catalogEntryPublicId } → 204. | | DELETE /waas/producer/{tenant}/packages/{pkg}/streams/{catalogEntryPublicId} | Remove a stream from a package → 204. Idempotent. | | POST /waas/producer/{tenant}/packages/{pkg}/archive | Archive a package → 204. | ## Access control (block / un-block) Since everything published is available by default, you limit a specific integration by** blocking** a stream or a whole package. A block has teeth: it stops (re)subscription and pauses any live subscription so routing halts. Un-blocking resumes it. A package block **wins** over the stream's other package memberships. | Endpoint | What it does | | --- | --- | | POST /waas/producer/{tenant}/integrations/{integration}/blocks/streams/{catalogEntryPublicId} | Opt an integration out of one stream (block) → 204. Pauses any subscription to it. Idempotent. | | DELETE /waas/producer/{tenant}/integrations/{integration}/blocks/streams/{catalogEntryPublicId} | Un-block a stream → 204. Resumes a paused subscription (unless still covered by a package block). | | POST /waas/producer/{tenant}/integrations/{integration}/blocks/packages/{pkg} | Opt an integration out of a whole package (block) → 204. Pauses subscriptions to its streams. Idempotent. | | DELETE /waas/producer/{tenant}/integrations/{integration}/blocks/packages/{pkg} | Un-block a package → 204. Resumes subscriptions no longer covered by any block. | ## Sandbox publish The sandbox route mirrors production ingress but lets you simulate delivery failures with query params — useful for testing retries and circuit behavior without a real receiver. | Route / param | Meaning | | --- | --- | | POST /events/sandbox/{tenant}/{queue} | Sandbox ingress. Identical to the production route, but the sandbox query params below are honored here (and rejected with 400 on the production route). | | ?sb_fail_code=503 | Force the simulated delivery to fail with this status code. | | ?sb_fail_count=2 | Fail the first N delivery attempts, then succeed. | | ?runId=... | Attach the event to a sandbox run. | ## Related - [Queuey Connect](https://queuey.ai/docs/how-to/connect) — the same steps in the console, and what the integration partner sees. - [Headers & status codes](https://queuey.ai/docs/reference/headers) — the ingress header and error-envelope reference. - [Quickstart](https://queuey.ai/docs/quickstart) — send your first event. # Signed local delivery with Edge and .NET — deliver to localhost with queuey listen A hosted Queuey never reaches localhost. Forward a queue's deliveries to your machine with queuey listen, signed, and verify them in .NET: the deployment file, the keys, Edge or Queuey.Client, and what happens when a forward fails. Source: https://queuey.ai/docs/how-to/local-delivery --- How-to guide # Signed local delivery with Edge and .NET A hosted Queuey never reaches `localhost`: its delivery does not go to your machine, a loopback address or a private network, and a delivery URL that points there is refused when you save it. To receive a queue's deliveries on your machine, set the queue to forward to a local listener and run `queuey listen`. The listener holds an outbound session to Queuey, so there is no port to open and no tunnel. This page sets up a .NET app that publishes with Queuey Edge and receives its own events, signed and verified. Package version This page requires Queuey.Edge and Queuey.Client ≥ 0.1.0-preview.9 (newer than 0.1.0-preview.8), and the queuey CLI of the same version. ## 1. The deployment file A dev workspace that takes signed requests only, and a queue that forwards to your listener and signs each delivery. Forwarding to a listener works only in a workspace marked dev. queuey.deploy.json ```json { "workspace": { "environment": "${QUEUEY_WORKSPACE_ENVIRONMENT}", "ingress": { "authMode": "SignedRequest", "signedRequest": { "template": "queuey" } } }, "queues": { "orders": { "delivery": { "url": "/webhooks/queuey", "kind": "localForward", "signing": { "enabled": true, "templateKey": "queuey", "credentialRef": "shop-delivery" } } } }, "profiles": { "dev": { "variables": { "QUEUEY_WORKSPACE_ENVIRONMENT": "dev" } } } } ``` The queue's `url` is a path, `/webhooks/queuey`, with no host: it is where your app receives, and the path (and any query) the signature covers. The listener puts your `--forward-to` in front of it. Without a path, the delivery goes to `/{the queue's id}` (`/que_…`), signed all the same. A path with no `workspace.delivery.baseUrl` under it is only the listener's. Over HTTP the queue has no destination until the workspace has a base URL, and a test send or verify-and-resume answers *no target*. `queuey plan` says so. ## 2. Log in, make the keys, apply bash ```bash queuey login --profile dev --json # show the person the link; then: queuey login --profile dev --wait queuey whoami --profile dev # the dev workspace's id (ten_…), for QUEUEY_TENANT below # Both secrets go straight into .env (git-ignored), the same value as in Queuey, never shown: queuey keys mint --profile dev --write .env # the sender: QUEUEY_SIGNING_KEY_ID, QUEUEY_SIGNING_SECRET queuey credentials generate shop-delivery --profile dev --write .env # the receiver: QUEUEY_DELIVERY_SECRET queuey plan --profile dev # what apply would change queuey apply --profile dev ``` `credentials generate` comes before `apply`: a delivery that names a credential the workspace does not have is refused. If the login found no dev workspace, make one with `queuey create-tenant --name shop-dev --environment dev` and pass its id with `--tenant`. A workspace without an environment counts as prod, where keys wait for a person's approval. ## 3. Listen bash ```bash queuey listen --queue orders --forward-to http://localhost:5080 --profile dev ``` The listener sends each delivery to `--forward-to` with the method, the path and the query of the queue's delivery URL, its headers — the signature headers included — and its body, unchanged. Only the host is swapped, and `X-Queuey-Queue` is added. Since the path and the query are the ones Queuey signed, your receiver verifies against the URL it was called on. The endpoint's own credentials and static headers are not forwarded. Your app has 18 seconds to answer. `--forward-exact` posts the request to `--forward-to` exactly as given. The signature still covers the path in the queue's configuration, so the two must be the same: `--forward-to http://localhost:5080/webhooks/queuey --forward-exact` for the file above. With another path, the signature check fails. ## When a forward does not go through - **No listener:** the events wait. The queue holds, and forwards as soon as a listener connects. - **A failure the queue would retry over HTTP** — by default a 5xx, 408 or 429, a timeout or a network error — is sent again at most three attempts in all, within the queue's own retry policy: its backoff or a `Retry-After`, the status codes it excludes from retrying or adds to it, and its number of attempts. A queue that allows one attempt gets no retry. After the last attempt the event goes to the DLQ. - **A timeout** against a receiver the queue does not mark as idempotent follows the queue's timeout behaviour (`TimeoutBehavior`): with Hold, the default, the event is held instead of sent again. - **Any other 4xx** goes straight to the DLQ: sending it again would get the same answer. - **The same event can come more than once**, as after a listener went away mid-forward. Deduplicate (step 4). Fix the app, then send the events again: bash ```bash queuey replay evt_… --queue que_… # one event to your listener, read-only queuey replay --queue orders --status dlq --profile dev # the DLQ, delivered again ``` The first sends one stored event to your listener without changing it, also one in the DLQ; the queue must share its payloads in full. The second sends the queue's DLQ events again, to your listener. ## 4. The app Program.cs ```csharp using System.Collections.Concurrent; using Queuey.Client; using Queuey.Edge; var builder = WebApplication.CreateBuilder(args); // The sender. Edge commits each event to a local spool before PublishAsync returns, // and signs it with the workspace's key when it sends it. builder.Services.AddQueueyEdge(o => { o.UseEnvironmentVariables(); // QUEUEY_SIGNING_KEY_ID + _SECRET: the environment, or .env in Development // QUEUEY_TENANT: the environment only (launchSettings.json below) // Durable: not under bin/, which a rebuild or dotnet clean can empty. Git-ignore .queuey/. o.Storage.Path = Path.Combine(builder.Environment.ContentRootPath, ".queuey", "spool.db"); }); // The receiver. QUEUEY_DELIVERY_SECRET: the environment, or .env in Development. var seenNonces = new ConcurrentDictionary(); // keep them a little over five minutes in a real app var verifier = QueueyDeliveryVerifier.FromEnvironment(new QueueyDeliveryVerifierOptions { NonceAlreadySeen = nonce => !seenNonces.TryAdd(nonce, 0), }); var handled = new ConcurrentDictionary(); // your database in a real app var app = builder.Build(); app.MapPost("/orders", async (Order order, IQueueyPublisher queuey) => { await queuey.PublishAsync("orders", order, new PublishOptions { EventType = "order.created", IdempotencyKey = order.Id, // a retried request is stored once }); return Results.Accepted(); }); app.MapPost("/webhooks/queuey", async (HttpRequest request) => { using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer); // the raw bytes, before anything deserializes them var body = buffer.ToArray(); var result = verifier.Verify( request.Method, new Uri($"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}"), name => request.Headers[name], body); if (!result.IsValid) return Results.Unauthorized(); // A redelivery of an event already handled: answer 200, do nothing. if (result.EventId is { } eventId && !handled.TryAdd(eventId, 0)) return Results.Ok(); // … handle the order return Results.Ok(); }); app.Run(); record Order(string Id, decimal Total); ``` Properties/launchSettings.json ```json { "profiles": { "shop": { "commandName": "Project", "applicationUrl": "http://localhost:5080", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "QUEUEY_TENANT": "ten_…" } } } } ``` .gitignore ```text .env .queuey/ ``` `dotnet run` takes the launch profile, so the app runs as Development and reads `.env`; without one it runs as Production and reads neither `.env` nor user secrets. The receiver deduplicates on the event id, which is safe only when the v2 signature covers it; the verifier in Queuey.Client checks v1 today, so the nonce guard stays on. What each version covers is in [Verify deliveries](https://queuey.ai/docs/how-to/verify-deliveries). To publish with [Queuey.Client](https://queuey.ai/docs/reference/dotnet-client) instead of Edge: Program.cs ```csharp // Queuey.Client instead of Edge: no spool, the publish goes straight to the ingress. builder.Services.AddQueueyClient(o => o.UseEnvironmentVariables()); app.MapPost("/orders", async (Order order, QueueyClient queuey) => { await queuey.Ingress.PublishAsync("orders", order, new PublishOptions { EventType = "order.created", IdempotencyKey = order.Id }); return Results.Accepted(); }); ``` ## What is read from where `UseEnvironmentVariables()` and `QueueyDeliveryVerifier.FromEnvironment()`, Edge and Client alike. A value set in code wins, and the environment wins over `.env`, which is read only in Development (`ASPNETCORE_ENVIRONMENT` or `DOTNET_ENVIRONMENT`), from the working folder, and only when it is a regular file of your own that git does not track. | Variable | Read from | | --- | --- | | QUEUEY_SIGNING_KEY_ID, QUEUEY_SIGNING_SECRET | The environment, or in Development .env in the working folder, read as a pair. Written by keys mint --write .env. | | QUEUEY_DELIVERY_SECRET | The environment, or in Development .env in the working folder. Written by credentials generate --write .env. | | QUEUEY_TENANT (the workspace id) | The environment only, never .env. Set it in launchSettings.json, the shell, or in code as TenantPublicId. queuey whoami --profile dev shows it. | | QUEUEY_INGRESS_BASE, QUEUEY_API_BASE | The environment only. Only when your Queuey is not queuey.ai. | | QUEUEY_API_KEY | The environment only, and not at all while the signing pair is set. | ## Related - [Queuey Edge](https://queuey.ai/docs/how-to/edge) — the spool, the daemon, and running Edge on a device. - [Verify deliveries](https://queuey.ai/docs/how-to/verify-deliveries) — the signature, in .NET and elsewhere. - [Set up with your AI](https://queuey.ai/docs/agent-setup) — the prompt that does all of this for you. # Security & compliance — data residency, GDPR, isolation & controls How Queuey protects your data: EEA hosting in Azure Norway East, per-tenant isolation, encryption, GDPR DPA with SCCs, configurable retention, sub-processors, and enterprise security FAQ. Source: https://queuey.ai/docs/security --- Trust & compliance # Security & compliance at Queuey Queuey carries your events — which means it carries your customers' data. This page describes how that data is protected: where it lives, who can touch it, how it is isolated and encrypted, and what our GDPR posture is. It is written to answer the questions an enterprise security review actually asks, and we keep it honest — including about what we don't have yet. EEA data residency Hosted on Microsoft Azure in the Norway East region. Primary processing of customer data stays inside the EEA. Per-tenant isolation Every tenant gets its own database schema, and all access is scoped per tenant — one tenant can never read another's data. Encrypted in transit & at rest TLS on ingress, delivery, console, and API. Data at rest is encrypted by Azure's managed-database platform encryption. GDPR-first Built and operated from Norway under the GDPR. An Article 28 Data Processing Agreement with EU Standard Contractual Clauses backs enterprise use. You control retention Retention follows your plan: 7 days on Free, 30 on Team, 90 on Growth, and no time limit on Enterprise unless one is agreed. A shorter retention you configure always wins. Expired data is deleted by an automated daily job. Authenticated end to end API keys, HMAC signed requests, and source-IP allowlists on the way in; HMAC signatures and OAuth2 (incl. private_key_jwt) on the way out. ## Data residency & hosting Queuey runs on Microsoft Azure in the **Norway East** region — inside the EEA — including its managed databases and backups. Primary processing of customer data takes place within the EEA, and no customer data is transferred outside the EEA (by Queuey or a sub-processor) unless a GDPR Chapter V safeguard — an adequacy decision or the EU Standard Contractual Clauses with supplementary measures — is in place for that transfer first. Two named sub-processors process limited data in the United States under those clauses: Resend (notification e-mail — recipients and operational notification content, never event payloads) and OpenAI (AI-assisted failure analysis, only where you enable it per queue). Both are listed in the DPA and in the sub-processor table on this page. Queuey is a Norwegian company. Agreements are governed by Norwegian law, and the competent supervisory authority is the Norwegian Data Protection Authority (Datatilsynet). ## Tenant isolation & access control - **Schema-per-tenant.** Every tenant's data lives in its own database schema, and each connection is scoped to exactly one tenant's schema. Cross-tenant reads are not a query-filter discipline — they are structurally impossible. - **Scoped credentials.** API keys belong to an API client on your license, and can be scoped to the whole license or to one workspace. You create and revoke keys in the console at any time; to rotate a key, create a new one and revoke the old. - **Federated console sign-in.** The console authenticates through Microsoft Entra ID; Queuey stores no passwords. - **Least privilege internally.** Production access is limited to authorised personnel on a need-to-know basis, under confidentiality obligations. - **Authorised distribution only.** Under WaaS event distribution, an event flows from a producer to a subscribing tenant only where the producer's own configuration authorises that delivery. ## Product security controls Security is part of the delivery pipeline itself, not a bolt-on: - **Ingress authentication** — API keys or [HMAC signed requests](https://queuey.ai/docs/how-to/signed-requests) (HMAC-SHA256 over a canonical string, with replay protection). - **Network gate** — a per-queue [source-IP CIDR allowlist](https://queuey.ai/docs/how-to/ip-allowlist) evaluated against the real client IP, before any authentication runs. - **Signed deliveries** — outbound requests can carry an HMAC signature your receiver [verifies in constant time](https://queuey.ai/docs/how-to/verify-deliveries), proving origin and integrity. A queue that receives Stripe webhooks can sign in Stripe's format instead ([Stripe signature recalculation](https://queuey.ai/docs/how-to/stripe)); Queuey then signs only the unchanged bytes of events that arrived through the queue's Stripe-verified ingress after recalculation started, never an event Stripe did not send. - **Delivery-side auth** — OAuth2 client credentials against your target, including [private_key_jwt with a P12 certificate](https://queuey.ai/docs/how-to/oauth2-private-key-jwt) for regulated receivers. - **Duplicate-safety** — idempotency-based dedup at ingress, key threading outbound, and Hold-on-timeout as the safe default for non-idempotent endpoints. - **Loop prevention** — the X-Queuey-Path hop chain and cycle detection stop events from circulating forever between queues. - **Sandbox separation** — test traffic runs in a sandbox separated from production processing. ## GDPR & data protection For the event data you send through Queuey, **you** are the controller (or a processor for your own customers) and **Queuey is a processor**: we process that data only on your documented instructions — constituted by your configuration of queues, endpoints, policies, and retention — and never for our own purposes. - A **GDPR Article 28 DPA** with schedules for processing details, technical and organisational measures, sub-processors, and international transfers (EU SCCs 2021/914) is part of the commercial agreement. - **Sub-processor changes** come with at least 30 days' prior notice and a right to object. - **Breach notification** without undue delay — where feasible within 72 hours of Queuey becoming aware. - **Data-subject requests** — the console, API and SDK let you access, correct, export and delete data self-serve; Queuey assists where self-service can't reasonably reach. - **Deletion on exit** — 30-day export window after termination, then deletion from production systems. What you send is up to you Queuey is a general-purpose event-delivery platform and is not designed for special categories of personal data (GDPR Article 9) or data about criminal convictions (Article 10). Don't route such data through Queuey unless additional safeguards have been agreed in writing. ## Retention, export & deletion Retention follows your plan: 7 days on Free, 30 on Team and 90 on Growth. On Enterprise, event data is kept with no time limit unless a limit is agreed. A shorter retention you configure always wins. An automated job enforces the policy daily — expired events, delivery attempts and metrics are deleted, not just hidden. During the subscription you can export and delete data at any time through the console and API. ## Sub-processors Queuey uses a deliberately short list of sub-processors: | Sub-processor | Purpose | Processing location | | --- | --- | --- | | Microsoft Azure | Cloud hosting and managed database | Norway East (EEA) | | Stripe | Payment and billing processing | EEA entity (Stripe Payments Europe) | | Resend | Transactional and notification email | United States | | OpenAI | AI-assisted failure analysis — engaged only where a queue's AI data control is set to Shape or Full; never when Off | EEA (OpenAI Ireland Ltd); onward transfer to the United States under the EU Standard Contractual Clauses | Email (Resend) is used for account and alert notifications — not for the contents of your event payloads. Sub-processor additions or replacements are announced at least 30 days in advance, with a contractual right to object. ## Certifications & audits — the honest version Queuey does not yet hold ISO/IEC 27001 or SOC 2, and we won't pretend otherwise with borrowed badge walls. What we offer today: written answers to your security questionnaire, the information needed to demonstrate GDPR Article 28 compliance, and contractual audit rights in the DPA. As certifications are attained they will be listed here — and not a day before. ## Reporting a vulnerability If you believe you have found a security vulnerability in Queuey, email [security@queuey.ai](mailto:security@queuey.ai) with enough detail to reproduce it. We ask that you give us a reasonable window to remediate before public disclosure, and that testing never targets data belonging to other tenants. We do not take legal action against good-faith research conducted under these rules. ## Receiving unwanted traffic from Queuey? Queuey delivers webhooks on behalf of our customers. If your server is receiving traffic you did not ask for, you can always identify it: every request we send carries the User-Agent `Queuey-Delivery/1.0 (+https://queuey.ai/abuse)` — this identification cannot be disabled by the sender. Email [abuse@queuey.ai](mailto:abuse@queuey.ai) with the URL being hit, a rough time window, and — if present — the values of any `X-Queuey-Path` or `X-Queuey-Event-Id` headers from the requests. Those values let us trace the traffic to its exact source queue immediately. We investigate every report, suspend the offending queue at the platform level (the sender cannot lift the suspension), and reply to you with the outcome. Note that our delivery engine also throttles itself automatically: endpoints that reject or fail traffic are rapidly backed off to a slow probe cadence rather than hammered. ## Enterprise security FAQ ### Where is our data stored and processed? On Microsoft Azure in the **Norway East** region, inside the EEA. Queuey does not move customer data outside the EEA without an appropriate GDPR Chapter V safeguard (adequacy decision or Standard Contractual Clauses) in place first. ### Is our data encrypted? Yes. All traffic — ingress, delivery to your endpoints, the console, and the API — runs over TLS. Data at rest in the managed database and its backups is encrypted by Azure platform encryption. Outbound deliveries can additionally be [HMAC-signed](https://queuey.ai/docs/how-to/verify-deliveries) so your receiver can verify authenticity and integrity. ### Does Queuey use AI, and does it see our event data? Only if you let it. Each queue has an AI data control: **Off** (nothing leaves Queuey), **Shape** (payload structure only, values masked) or **Full**. Under Shape or Full, failure analysis is performed by OpenAI — OpenAI Ireland Ltd, with onward transfer to the United States under the EU Standard Contractual Clauses — and model outputs are not retained by the provider for training. Delivery decisions themselves are rule-based and never made by a model, and every AI read of payload content is recorded in your payload access log. ### How is our data isolated from other tenants? Each tenant has its own database schema, and every connection is scoped to that tenant's schema. API keys are scoped to a tenant (and can be scoped to a queue), so a credential for one tenant cannot touch another. In WaaS event distribution, an event only reaches a subscriber the producer's configuration explicitly authorises. ### Who at Queuey can access our data? Access to production and customer data is restricted to authorised personnel on a need-to-know basis, under confidentiality obligations and the principle of least privilege. Queuey processes your event data only as a **processor**, on your documented instructions — never for its own purposes, and it is never sold. ### Do you offer a Data Processing Agreement (DPA)? Yes. A GDPR Article 28 DPA — covering processing details, technical and organisational measures, sub-processors with a 30-day change notice and right to object, and EU Standard Contractual Clauses for any transfer out of the EEA — is part of the commercial agreement. Contact [legal@queuey.ai](mailto:legal@queuey.ai). ### What happens if there is a data breach? Queuey notifies affected customers **without undue delay** after becoming aware of a personal-data breach — where feasible within 72 hours — including the nature of the breach, likely consequences, and the measures taken, and cooperates with your investigation and any supervisory-authority notification. ### How do we get our data out, and what happens when we leave? You can access, export, and delete your data self-serve through the console and API for the whole subscription. On termination, data remains exportable for 30 days, after which it is deleted from production systems; backup copies age out with the backup rotation cycle. ### Do you hold ISO 27001 or SOC 2? Not yet — Queuey does not currently claim certifications it does not hold. We answer security questionnaires, make information available to demonstrate GDPR Article 28 compliance, and the DPA grants contractual audit rights. Certifications will be pursued as the company grows; this page will state them when they are real. ### Can we restrict who is able to send events to our queues? Yes — three gates, combinable: a per-queue [source-IP (CIDR) allowlist](https://queuey.ai/docs/how-to/ip-allowlist) evaluated before authentication, tenant-scoped API keys, and [HMAC signed-request authentication](https://queuey.ai/docs/how-to/signed-requests) with replay protection. ### How do users sign in to the console? Console sign-in is federated through **Microsoft Entra ID** — Queuey does not store passwords. Programmatic access uses API keys, scoped to your license or to one workspace, that you can create and revoke in the console. ### Can duplicate deliveries cause duplicate side effects in our systems? Queuey is built to prevent it: inbound deduplication on the Idempotency-Key, outbound key threading to your receiver, and a safe [Hold-on-timeout](https://queuey.ai/docs/how-to/reliable-delivery) default so a non-idempotent endpoint is never blindly retried after an ambiguous failure. ### Is Queuey available to consumers? No. The service is offered exclusively to businesses and organisations (B2B), under Norwegian law with Trøndelag District Court (Trondheim) as venue. ## Contact - Security reports — [security@queuey.ai](mailto:security@queuey.ai) - Privacy & data protection — [privacy@queuey.ai](mailto:privacy@queuey.ai) - DPA, agreements & questionnaires — [legal@queuey.ai](mailto:legal@queuey.ai) - Abuse — [abuse@queuey.ai](mailto:abuse@queuey.ai) # Queuey — the webhook gateway and async queue for your API The async queue for your API, as a webhook gateway: events stored before the answer, then delivery with retries, DLQ, idempotency, HMAC & OAuth2 signing and per-key ordering. Source: https://queuey.ai/docs/webhook-gateway --- # A reliable webhook gateway & delivery service Queuey is **the async queue for your API**, and it works as a webhook gateway: a managed layer between your events and your targets that stores each event before it answers, then handles delivery, retries, a dead-letter queue, idempotency, request signing and ordering — so your team ships integrations instead of rebuilding delivery plumbing. ## What a webhook delivery service should do If you're comparing managed webhook and event-delivery services, these are the capabilities that separate "fire an HTTP request and hope" from delivery you can operate: - Automatic retries with exponential backoff and jitter - A dead-letter queue you can inspect and replay - Idempotency on both ingress and delivery - HMAC signing and OAuth2 client-credentials auth (incl. private_key_jwt) - Per-key ordering without head-of-line blocking - A source-IP allowlist and signed-request ingress auth - Operational tooling: diagnose failing targets, clear & resume, replay ## Built to operate delivery, not just attempt it Most webhook tooling stops at "retry and log". Queuey adds the operational layer: when a target goes down it surfaces an issue with target-health state, holds events safely instead of duplicating side effects, and lets you clear, resume, and replay. That's the difference between a delivery service and a delivery *engine*. [Send your first event](https://queuey.ai/docs/quickstart) ## Learn more - [Do you need a queue in front of your webhook handler?](https://queuey.ai/docs/how-to/provider-webhooks) — the honest version, with what Stripe, Shopify and GitHub do on their own. - [Core concepts](https://queuey.ai/docs/concepts) - [Reliable delivery: retries, idempotency & DLQ](https://queuey.ai/docs/how-to/reliable-delivery) - [OAuth2 & private_key_jwt delivery auth](https://queuey.ai/docs/how-to/oauth2-private-key-jwt)