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.
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.
{
"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
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 devcredentials 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
queuey listen --queue orders --forward-to http://localhost:5080 --profile devThe 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:
queuey replay evt_… --queue que_… # one event to your listener, read-only
queuey replay --queue orders --status dlq --profile dev # the DLQ, delivered againThe 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
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<string, byte>(); // 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<string, byte>(); // 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);{
"profiles": {
"shop": {
"commandName": "Project",
"applicationUrl": "http://localhost:5080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"QUEUEY_TENANT": "ten_…"
}
}
}
}.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. To publish with Queuey.Client instead of Edge:
// 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 — the spool, the daemon, and running Edge on a device.
- Verify deliveries — the signature, in .NET and elsewhere.
- Set up with your AI — the prompt that does all of this for you.