# Callback URLs — letting an outsider complete one slot

> Mint a single-use link that completes exactly one waiting slot, for a third party who has no account and cannot sign in. The link is the permission: it carries an unguessable token, works once, dies with the slot it was made for, and can never touch anything else.

<!-- id: workflow-callback-url · area: workflow · stability: stable · html: https://osysharp.com/reference/workflow/callback-url/ -->

## Summary        {#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.

```osy syntax
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      {#signature}
```osy syntax
<SlotAlias>.CallbackUrl()   // returns string — an absolute URL for this slot on this run
```

Callable inside a workflow handler body — a state's `enter`, a route arm, a milestone — where the alias of a
[`subscribe`](https://osysharp.com/reference/workflow/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    {#description}

### The other side: what the holder does with it   {#posting}
The link is answered by **POSTing the event's own arguments** to it as JSON:

```text
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   {#safety}
A callback link deliberately bypasses [`Candidates`](https://osysharp.com/reference/workflow/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   {#still-enforced}
A callback is a way *in*, not a way *around*. Everything else the slot declares still applies:

- a slot's [`Requires`](https://osysharp.com/reference/workflow/subscribe/) must 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 `After` predecessors 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?    {#authorize}
⚠ **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:

```osy syntax
[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 it
```

Both 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`](https://osysharp.com/reference/testing/redeem-callback/), asserting the refusal and the bypass together —
a gate that stopped evaluating `[Authorize]` for everybody would pass either half alone.

### Minting it again   {#reminting}
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   {#no-fan-out}
It cannot be called on a [fanned-out](https://osysharp.com/reference/workflow/fan-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       {#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:

```osy title="supplier confirmation" test app=workflow-callback-url
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:

```text
POST https://orders.example.com/api/workflow/callback/8Kj2mQ...   {"ok": true}
```

### The link survives a deploy — including one that changes the event   {#deploys}
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](https://osysharp.com/reference/workflow/migration/).

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       {#see-also}
- [subscribe](https://osysharp.com/reference/workflow/subscribe/) — the slot the link completes, and its `Requires` gate
- [Candidates (slot)](https://osysharp.com/reference/workflow/candidates/) — the pool a callback deliberately bypasses, and why that is bounded
- [Raising a workflow event](https://osysharp.com/reference/workflow/raise/) — advancing a run from app code, where a principal does exist
- [Acting on an inbox row (deposit, claim, release)](https://osysharp.com/reference/workflow/inbox-act/) — a signed-in person answering their own queued item instead
