Summary#
Some of the people a workflow waits on will never have a login. A supplier confirming a delivery date, a customer approving a quote, a lab returning a result — asking them to create an account to click "yes" is asking them not to answer.
CallbackUrl() mints a link for exactly that. Call it on a slot alias inside the workflow, email the result, and
whoever holds it can complete that one slot — no sign-in, no account, nothing else reachable.
enter {
var url = SupplierOk.CallbackUrl();
Email.Send(this.Item.Supplier.Email, $"Confirm this order: {url}");
}The link is the permission. That is a real decision rather than a shortcut, so the whole of it is written down under [[#description|What makes the link safe]] — read that before you send one somewhere it could be forwarded.
Signature#
<SlotAlias>.CallbackUrl() // returns string — an absolute URL for this slot on this runCallable inside a workflow handler body — a state's enter, a route arm, a milestone — where the alias of a
subscribe in that state is in scope. It returns a plain string, so it goes into an email,
onto the tracked row, or wherever the app needs it.
Description#
The other side: what the holder does with it#
The link is answered by POSTing the event's own arguments to it as JSON:
POST https://orders.example.com/api/workflow/callback/<token>
Content-Type: application/json
{ "ok": true }The body is the slot's event signature, by name — subscribe Confirm(bool ok) takes {"ok": true} and nothing else.
It is checked, not trusted: a missing parameter, a wrong type, or a property the event never declared is refused with
a message saying what the response takes. An event with no parameters is answered by posting nothing at all.
It is a POST rather than a GET on purpose. A link that acted on being fetched would be spent by the first mail scanner or link preview that touched it, before the human ever clicked.
What makes the link safe#
A callback link deliberately bypasses Candidates — there is no principal for a pool to
admit, which is the entire point. Five properties bound it, and together they are why that is sound:
| Unguessable | 256 bits of cryptographic randomness. Guessing one is not a slow attack; it is not an attack. |
| Single-use | The deposit that succeeds burns it. A forwarded link cannot be answered by a second party — which is a different problem from a double click, and the one that actually bites. |
| One slot, one run | It completes the slot it was minted for and nothing else. A leaked link risks exactly one wrong answer on one item; it can never be replayed against another. |
| Slot-lifetime | It works only while the slot is open. Once the item is completed, cancelled or its deadline has passed, the link is dead — a link that still works after the ticket closed is a bug. |
| Never stored | Only a hash of the token is kept. Reading the database — a backup, a support query — does not hand anyone the ability to answer. |
The link is returned once, from the call. Nothing can read it back afterwards, so put it where you need it in the same body that minted it.
What it does not relax#
A callback is a way in, not a way around. Everything else the slot declares still applies:
- a slot's
Requiresmust still hold — "you may not resolve without a root cause" is as true for a supplier as for a colleague; - a slot still waiting on its
Afterpredecessors is not open, and the link is refused until it is; - a run that has already finished accepts nothing.
Is [Authorize] evaluated on a callback deposit?#
⚠ An event's [[workflow-authorize|[Authorize]]] predicate is not evaluated either, and for the same reason
Candidates is not: a predicate takes a principal and a callback deposit has none. There is nothing to evaluate it
against, so it is skipped rather than failed.
This is worth stating plainly because the two declarations look like they compose and do not:
[Authorize(u => u.Email == this.Item.Email)] // nobody may accept on your behalf — TRUE of the button
event Accept();
…
enter { this.Item.AcceptLink = Acceptance.CallbackUrl(); } // …and the LINK is not governed by itBoth are correct and both are wanted — that IS an invite flow — but the second widens the first, and only the author can decide that is what they meant. Whoever can read the mail can complete the slot, which is exactly the authority a real emailed link carries. If that is not acceptable for a given event, do not mint a URL for it; there is no way to have the link and the predicate at once.
Pin it in a test with Workflow.Redeem, asserting the refusal and the bypass together —
a gate that stopped evaluating [Authorize] for everybody would pass either half alone.
Minting it again#
Calling CallbackUrl() a second time for the same slot issues a new link and retires the old one. That is what a
resend should do — sending to a corrected address must not leave the first address still able to answer.
One slot at a time#
It cannot be called on a fanned-out slot: one alias there is one slot per element, so a single link could not say which one it completes. The compiler refuses it rather than picking.
Examples#
A supplier who is not a user of the app confirms an order. Note that Candidates admits only staff — the supplier is
not in the pool, and the link is the only way they can answer:
enum OrderState { Awaiting, Confirmed, Refused }
[Principal]
entity Person {
[Required, MaxLength(100)] string Name;
bool IsStaff;
security { allow read when IsAuthenticated; allow create when IsAuthenticated || IsAnonymous; }
}
entity Order {
[Required, MaxLength(100)] string Reference;
[Required, MaxLength(200)] string SupplierEmail;
OrderState State = OrderState.Awaiting;
[MaxLength(400)] string? ConfirmLink;
security { allow read when IsAuthenticated; allow create, update when IsAuthenticated || IsAnonymous; }
}
workflow OrderFlow {
Tracks = Order.State;
Initial = Awaiting;
event Confirm(bool ok);
state Awaiting {
subscribe Confirm(bool ok) as SupplierOk {
Candidates = u => u.IsStaff;
}
enter {
this.Item.ConfirmLink = SupplierOk.CallbackUrl();
}
on SupplierOk(bool ok) {
when (ok) { goto Confirmed; }
default { goto Refused; }
}
}
terminal success Confirmed { }
terminal cancel Refused { }
}The supplier answers it with a single request:
POST https://orders.example.com/api/workflow/callback/8Kj2mQ... {"ok": true}The link survives a deploy — including one that changes the event#
You cannot recall a URL that is already in somebody's inbox, and the person holding it has no way to learn you redeployed. So the guarantee is not "the link works until the next deploy": a link keeps working across deploys, and across a change to the very event it completes.
The token records the version it was minted under. When the body arrives it is read against THAT version's signature
and translated forward into the one the run is on now, using the mappings each deploy authored. A deploy that changes
an event's parameters without saying what the old shape means is refused — see map event in Migrating runs that are still in flight.
What DOES end a link is what always ended it: the slot closing, the deposit burning it, or an author's
drop slot, which cancels the claim deliberately.
See also#
- subscribe — the slot the link completes, and its
Requiresgate - Candidates (slot) — the pool a callback deliberately bypasses, and why that is bounded
- Raising a workflow event — advancing a run from app code, where a principal does exist
- Acting on an inbox row (deposit, claim, release) — a signed-in person answering their own queued item instead