StoryLink

API

One question. Every answer kept.

An agent sends one assumption to the person who owns the answer, or the same question to several people, or a user story for them to complete. StoryLink emails each person their own link, reminds them, and returns what they sent back. StoryLink does not decide whether your workflow may proceed, and it does not verify who clicked the link. It gets a business answer from a named person who is not in your tools, and gives it back with the record of what they were shown.

The whole path

One question goes out. Each person answers alone.

An agent or a person can ask one person or several. On a decision they confirm, replace, reject, or send it back. On a story or a list of questions you read the answers. Anything else leaves the request open.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

1/6 An agent or a person sends one question, to one person or to several.

Who asks

An agent

POST /api/v1/requests, ask_decision, or ask_request. One idempotency key sends one email.

A person

The same request, sent by hand from the workspace.

What is held

One assumption

Frozen after send, so the record is what they were shown. The same question can go to several people.

The chase

Each person gets one link and no account. Reply-To is the requester. Reminders at 24h and 72h stop on a response.

What you read

A resolution

confirmed, modified, or rejected. Signed callback. The poll returns the same bytes. The workflow still decides whether to proceed.

No resolution

A story, a list of questions, or a request still open. decision stays null. Read each response. A missing answer stays missing.

What the owner can do

Confirm

The assumption stands.

Modify

Use replacementValue. The assumption stays on record.

Reject

No replacement. The note says why.

Send it back

Needs more, or it cannot be determined. The link stays open.

What ends it without an answer

Not delivered

Bounce or complaint. Reminders stop. Nobody saw the question.

Expired

The link lifetime passed. Nobody resolved it.

Canceled

The requester closed it.

Authentication

Create a key on the Agents page. Send it as Authorization: Bearer sl_…. Keys belong to a workspace; a request created by one key is visible to every key in that workspace. Revoked and unknown keys both get 401.

Create a decision

POST https://www.liason.app/api/v1/requests
Authorization: Bearer sl_…
Idempotency-Key: mrr-revenue-timing-dbt-342
Content-Type: application/json

{
  "title": "MRR report: subscription revenue timing",
  "context": "The monthly MRR model sums invoices by issue date. Three subscription contracts are billed annually in advance.",
  "question": "Recognize subscription revenue on invoice issuance in the monthly MRR report?",
  "assumption": "Recognize on invoice issuance.",
  "contributorEmail": "finance-lead@client.example",
  "contributorName": "Grace",
  "externalRef": "DBT-342",
  "callbackUrl": "https://agent.example.com/storylink/hook",
  "metadata": {
    "path": "models/finance/mrr.sql",
    "symbol": "recognized_at"
  }
}
title
Short name. 3–160 characters. Shown as the email subject and page heading.
question
The one question. 3–300 characters.
assumption
The value you would apply if nobody objected. Frozen for the life of the request.
context
Optional. Facts the person needs, shown above the assumption. Up to 4,000 characters.
contributorEmail
One address. Or pass contributors with several. Each person gets their own link.
contributorName
Optional. Used in the greeting.
externalRef
Optional. Your id: ticket, model, run, invoice. Echoed back.
callbackUrl
Optional. https only. POSTed once a resolution exists. See Callback.
metadata
Optional JSON object, up to 8 KB. Echoed back unchanged with keys sorted.

201 with the resource below on first create. 200 with the same resource when the same key is replayed.

Several people

Pass contributors with one or more addresses. contributorEmail is still the one-person form, and it is added to the list if you send both. Each person receives a separate link and submits their own answer. StoryLink does not merge them.

{
  "type": "decision",
  "title": "Revenue recognition rule",
  "question": "When should revenue be recognized?",
  "assumption": "At invoice issuance",
  "contributors": ["cfo@example.com", "finance@example.com"],
  "collection": { "mode": "all", "deadline": "2026-12-01T18:00:00Z" }
}
collection.mode
all waits until everyone who can still answer has. deadline closes at the time you set.
collection.deadline
Optional with all, required with deadline. In the future, within 90 days. Not the link lifetime.
responses
One entry per person. Read this whenever more than one person was invited.
decision
Stays null when several people were invited, even if they all agree. Do not invent a winner.

A person who has not answered is listed with their status. Their silence is not confirmation. A bounce removes that person from the wait: the others can still finish the request. At a deadline, whatever has arrived is returned. If nobody has answered, the request expires and no callback is sent.

Stories and questions

The same endpoint creates the requests a person can already send by hand. type: "story" asks someone to complete a user story. type: "questions" asks them the prompts you send. There is no assumption and no resolution. decision stays null. Read responses[].answers. A question can list options. The stored answer is the option id. evidence is a short list of labeled text, such as an excerpt or a dictionary entry. reference is a label, an id, and a URL the person can open. externalRef is that id when you send it on its own.

{
  "type": "questions",
  "title": "Fact table grain",
  "context": "The warehouse model is blocked on this.",
  "questions": [
    {
      "id": "grain",
      "prompt": "What is one row of the fact table?",
      "required": true,
      "options": [
        { "id": "invoice", "label": "One invoice", "consequence": "Revenue is recognized per invoice." },
        { "id": "line", "label": "One invoice line", "consequence": "Revenue is recognized per line." }
      ]
    }
  ],
  "evidence": [
    { "label": "Dictionary", "text": "grain: the thing one row represents." }
  ],
  "reference": { "label": "Metric", "id": "fact_invoice", "url": "https://warehouse.example/metrics/fact_invoice" },
  "contributorEmail": "analyst@example.com"
}

Idempotency

Idempotency-Key is required: 8–200 characters from letters, digits, and . : _ -. Use one stable key per question, for example the ticket id plus the symbol that is waiting. A retry with the same key and the same body returns the existing decision and sends no second email. The same key with a different body is refused with 409. Keys are scoped to the workspace.

The key is linked to the row before the email goes out. If the email fails you get 502 with the id; retry with the same key and the send is attempted again.

Poll

GET https://www.liason.app/api/v1/requests/{id}
Authorization: Bearer sl_…
{
  "id": "3f1c0a7e-6b24-4d91-8e55-0a9c2d7b14f8",
  "url": "https://storylink.example/api/v1/requests/3f1c0a7e-…",
  "externalRef": "DBT-342",
  "status": "open",
  "title": "MRR report: subscription revenue timing",
  "contributorEmail": "finance-lead@client.example",
  "sentAt": "2026-11-02T15:00:00.000Z",
  "openedAt": null,
  "expiresAt": "2026-11-16T15:00:00.000Z",
  "completedAt": null,
  "metadata": {
    "path": "models/finance/mrr.sql",
    "symbol": "recognized_at"
  },
  "pending": null,
  "deliveryFailedAt": null,
  "decision": null
}
status
draft · open · completed · expired · canceled. Only completed carries a decision.
decision
null until one person resolves a decision. Null for stories, and null when several people were invited.
responses
One entry per invited person, including people who have not answered.
pending
null, or the unresolved response under Pending. Only while status is open.
deliveryFailedAt
Set when the provider reported a bounce or spam complaint. Nobody saw the question.
openedAt
First time the link was opened in a browser. Mail scanners do not count.
expiresAt
When the link stops working. Set on first send from the workspace link lifetime.

Poll no more than once a minute per decision; the people who answer take hours or days. The read limit is 600 per key per minute. 429 carries Retry-After.

Resolution

Three layers are kept apart. The response is what the person typed. The resolution is whether an applicable answer now exists. Execution is what your workflow does next, and that is yours.

confirmed
The assumption stands exactly as written. replacementValue is null.
modified
Use replacementValue instead. The assumption is still in the payload for the record. Do not apply it. note may scope the replacement.
rejected
Do not apply the assumption. There is no replacement. note says why. Stop and report.

All three complete the request and stop reminders. A resolution is not permission to act. It tells you which value is correct.

{
  "version": 2,
  "id": "3f1c0a7e-6b24-4d91-8e55-0a9c2d7b14f8",
  "externalRef": "DBT-342",
  "status": "completed",
  "title": "MRR report: subscription revenue timing",
  "context": "The monthly MRR model sums invoices by issue date. Three subscription contracts are billed annually in advance.",
  "question": "Recognize subscription revenue on invoice issuance in the monthly MRR report?",
  "assumption": "Recognize on invoice issuance.",
  "resolution": "modified",
  "replacementValue": "Recognize monthly over the contract term.",
  "note": "Only for contracts billed annually in advance.",
  "contributorEmail": "finance-lead@client.example",
  "contributorName": "Grace",
  "completedAt": "2026-11-02T15:12:00.000Z",
  "metadata": {
    "path": "models/finance/mrr.sql",
    "symbol": "recognized_at"
  }
}

version is 2. Keys and their order are stable within a version. The callback body is this exact JSON; the poll returns the same object.

Pending

The person can send the question back without resolving it: they need more information, or they are not the right person, or it cannot be determined. The request stays open, decision stays null, reminders pause, and the requester is emailed. The poll shows:

"pending": {
  "kind": "needs_clarification",
  "note": "Which contracts? The annual ones are recognized monthly; the rest on invoice.",
  "at": "2026-11-02T15:11:00.000Z"
}

Treat pending as not resolved. If you can supply what the note asks for, open a new decision with it in context and a new key. The original stays open: the same link still works if the person later answers, and the requester can resend it to restart reminders. Pending is visible on the poll only; the callback is sent for a resolution.

Callback

If you gave a callbackUrl, StoryLink POSTs the decision object once a resolution exists. Delivery is a separate row from the decision: a failing endpoint never reopens the request, and the poll has the same object immediately.

X-StoryLink-Signature
HMAC-SHA256, hex, of `{timestamp}.{body}` with your workspace callback secret.
X-StoryLink-Timestamp
Unix seconds when this attempt was sent. Reject if older than a few minutes.
X-StoryLink-Delivery
Delivery id. Constant across retries of the same decision.
Retries
Up to 8 attempts over about 42 hours: 1m, 5m, 15m, 1h, 4h, 12h, 24h. Any 2xx stops them. Redirects are not followed.
Body
The exact same bytes on every attempt. Dedupe on id.

The first attempt goes out on the next sweep, within fifteen minutes of the response.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyStoryLink(req: { headers: Headers; rawBody: string }, secret: string) {
  const ts = req.headers.get("x-storylink-timestamp") ?? "";
  const sig = req.headers.get("x-storylink-signature") ?? "";
  // Reject anything older than five minutes.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${ts}.${req.rawBody}`)
    .digest("hex");
  const a = Buffer.from(sig, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Then: dedupe on body.id, read body.resolution, never body.assumption alone.

Lifecycle

draft
Created but not sent. Only possible for requests sent by hand.
open
Emailed. Reminders queued at the workspace cadence, 24h and 72h by default. Each email carries its own single-use link; only its hash is stored.
completed
A resolution exists. Reminders canceled. Callback queued.
expired
The link lifetime passed with no resolution, 14 days by default. Nobody resolved it. Do not apply the assumption.
canceled
The requester closed it. Same as expired for your purposes.

The assumption, the question, and the context are frozen once the request exists. Editing is refused, so the record always says what the person was shown.

Errors and limits

400
Invalid body or Idempotency-Key. The message names the field.
401
Missing, unknown, or revoked key.
404
No such decision in this workspace.
409
Idempotency-Key reused with a different body, or still in progress.
429
Rate limit, or the workspace daily send cap. Retry-After is set for rate limits.
502
Saved, but the email did not send. Retry with the same key.

Defaults: 60 creates and 600 reads per key per minute, 200 first sends per workspace per rolling 24 hours.

People and recipients

GET /api/v1/people?q=revenue+recognition searches the workspace directory. GET /api/v1/people/{id} reads one profile. POST /api/v1/people adds one. Facets are declarations by the workspace: responsibility is what they handle, expertise is what they know, authority is what they are designated to decide. A title is not authority. StoryLink does not verify any of it.

GET /api/v1/config says how this key addresses people. recipientMode is choose or named. The instruction field is the rule for that key.

choose
Call find people, then create the request with personIds in the same turn. An email that is not in the directory is rejected.
named, with people
Omit recipients. StoryLink sends to the people pinned on the key. A different recipient is rejected.
named, with nobody
Pass the email or personId written in the workflow definition. An email does not have to be in the directory.
{
  "title": "MRR report: subscription revenue timing",
  "question": "Recognize subscription revenue on invoice issuance?",
  "assumption": "Recognize on invoice issuance.",
  "personIds": ["6b8a0e2c-1d44-4f0a-9c3e-5a7b2d8f01c4"]
}

Projects

A project groups people and requests inside the workspace. It is not a second tenant, and a person can be on more than one. A request has one project or none. Moving it later changes only that association. The question and the responses stay as they were. Archiving a project keeps its requests.

GET /api/v1/projects lists active projects. GET /api/v1/projects/{id} reads the description, the short context, and who is on it. GET /api/v1/requests?projectId= lists recent requests with titles, who was asked, and decision resolutions. Story answers and drafts are left out. GET /api/v1/people?q=&projectId= still searches the whole directory and sets onProject for people already on that project.

project on GET /api/v1/config is the project pinned on this key, or null. Omit projectId on create to use that pin, or to leave the request workspace-wide when nothing is pinned. A pinned key rejects a different project. The idempotency hash does not include the project.

What this is not

The link proves possession of an email, not identity or authority. The signature proves the callback came from StoryLink unchanged, not that the human decision was legitimate. The event history is an ordinary database record. StoryLink is a dependable way to get a business answer from a named person outside your tools, with a record of what they were shown. It is not an authorization system and does not claim regulatory compliance on your behalf.