Documentation
DidWork verifies that software actually did what it says it did. You
send a claim; DidWork independently gathers
evidence from the systems that can prove the outcome
and returns a verdict. The API lives at
https://api.didwork.sh.
For agents there is a second question, answered before the work
runs: what can this tool actually affect? That is
Capability Trust.
Quickstart
Get an API key from the console (GitHub sign-in, no card). Your first verification needs no provider setup: this checks a claim against real GitHub state right now:
curl -s https://api.didwork.sh/v1/verify \ -H 'authorization: Bearer dk_YOUR_KEY' \ -H 'content-type: application/json' \ -d '{"type":"github.pr_merged","expected":{"repository":"cloudflare/workers-sdk","pull_request":15383}}'
{ "status": "verified", "evidence": [{ "source": "github", … }], … }
That verification is now in your console log, evidence included. From code:
# TypeScript
npm install @didwork/sdk
import { did } from "@didwork/sdk"; // reads DIDWORK_API_KEY from the environment const result = await did.verify({ type: "stripe.refund", expected: { payment: "pi_123", amount: 4999 } });
# Python
pip install didwork
from didwork import did verification = did.verify( type="stripe.refund", expected={"payment": "pi_123", "amount": 4999}, )
Authentication
Every request sends a bearer key. Keys are shown once at creation; only a hash is stored.
Authorization: Bearer dk_...
The SDKs read DIDWORK_API_KEY (and
optionally DIDWORK_BASE_URL) from the
environment, or accept explicit configuration.
One exception: http.ok claims run with
no key at all: rate limited, and not stored. Every other
claim type, and a log that keeps the evidence, needs a key.
Outcome contracts
A version 2 contract fixes the required checks, repository, full revision SHA and environment. DidWork gathers evidence for those checks and records one outcome run. A pass covers only the contract's required checks.
npx @didwork/inspect init --editor cursor \ --deployment-url https://your.app \ --repository your-org/your-app --environment production npx @didwork/inspect doctor npx @didwork/inspect verify --contract \ --trusted-ref FULL_REVIEWED_COMMIT_SHA \ --revision FULL_DEPLOYED_COMMIT_SHA
Review the generated .didwork.yml and its endpoints before committing it. The trusted commit must come from a protected workflow or repository setting. Protect the evaluator and workflow too. Only the revision placeholder is substituted; changes in the working copy cannot weaken the reviewed contract.
The starter checks GitHub deployment status, the application's /version JSON revision and its /readiness JSON ready flag. These checks do not establish login or checkout behavior. Add acceptance conditions appropriate to your application.
Use POST /v1/runs with a bearer key and an Idempotency-Key, then GET /v1/runs/:id to retrieve the result. Reuse the same key and identical contract after an interrupted request. Each evidence attempt is an ordinary verification; rejected quota requests are not billed. Optional settleSeconds allows up to four observations of a settling provider state, within a bounded deadline.
The SDK exposes did.verifyContract(contract, idempotencyKey) and did.getRun(id). MCP agents use did_verify_contract and did_get_run. The agent must preserve the reviewed requirements. Caller-supplied provenance is labeled as such.
Open Outcome runs to inspect private evidence, preview a redacted public summary, create a receipt link or revoke previous links. Public summaries omit customer URLs, expected values, repository/environment names and raw provider data. Receipts describe historical evidence, not ongoing guarantees.
Verify
POST https://api.didwork.sh/v1/verify { "type": "stripe.refund", "expected": { "payment": "pi_123", "amount": 4999 }, "subject": "order_18427", // optional caller-side reference "account": "acme" // optional: which connected account, by label }
Response:
{
"verification_id": "vrf_829...",
"status": "verified",
"claim": { ... },
"evidence": [
{ "source": "stripe", "observation": "1 refund(s) found for pi_123", "data": [...] }
],
"created_at": "...",
"verified_at": "..."
}
On failure the verdict carries a machine-readable
reason plus
expected / observed
where applicable: e.g.
AMOUNT_MISMATCH,
PR_CLOSED_NOT_MERGED,
RECIPIENT_MISMATCH.
Verdicts
Every verification ends in one of three states:
- VERIFIED: the evidence sufficiently proves the claim.
- FAILED: the evidence shows the claim is not true.
- UNKNOWN: the outcome can't be established: provider unreachable, record not visible, or not yet decidable (a pending refund, an in-progress workflow run).
UNKNOWN is deliberate. DidWork prefers uncertainty over false verification. Treat UNKNOWN as "do not proceed yet": retry, or route to a human.
Claim types
Stripe
| Type | Expected fields | Notes |
|---|---|---|
| stripe.refund | payment, amount?, currency? | Amounts in minor units. Detects wrong amounts, duplicates, pending and failed refunds. |
| stripe.subscription_cancelled | subscription | Scheduled-at-period-end is FAILED: the subscription is still active. |
| stripe.payment_succeeded | payment, amount?, currency? | Checks amount_received, not intent. |
| stripe.invoice_paid | invoice, amount? | Open invoices are UNKNOWN (may still settle); void/uncollectible fail. |
| stripe.subscription_active | subscription, price?, quantity? | Catches plan changes that never applied; past_due fails. |
| stripe.checkout_completed | session, amount?, currency? | An open session is UNKNOWN, because the customer may still be paying. Expired fails; so does complete-but-unpaid. |
| stripe.payout_paid | payout, amount?, currency? | pending and in_transit are UNKNOWN: the money is still moving. |
| stripe.payment_method_attached | payment_method, customer, last4? | Is the card actually on file for that customer, not merely created. |
GitHub
| Type | Expected fields | Notes |
|---|---|---|
| github.pr_merged | repository, pull_request, commit? | Closed-without-merge is FAILED. Optional merge-commit assertion. |
| github.workflow_passed | repository, run, commit? | Queued or in-progress runs are UNKNOWN, not failed. |
| github.issue_closed | repository, issue | A number that points at a PR fails with NOT_AN_ISSUE. |
| github.release_published | repository, tag | Drafts fail: published means published. |
| github.commit_in_branch | repository, branch, commit | Deploy check: the SHA is an ancestor of the branch head. |
| github.file_exists | repository, path, ref? | Artifact check; distinguishes a missing file from an invisible repo. |
| github.deployment_succeeded | repository, environment, commit? | In-progress deploys are UNKNOWN. The commit assertion catches a green deploy of the wrong revision. |
| github.pr_review_approved | repository, pull_request, reviewer? | Latest review per reviewer wins: an approval later withdrawn is not an approval. |
| github.branch_exists | repository, branch, commit? | Distinguishes a missing branch from an invisible repo; optional head assertion. |
GitLab
| Type | Expected fields | Notes |
|---|---|---|
| gitlab.mr_merged | project, merge_request | Merged, not merely closed. |
| gitlab.pipeline_passed | project, pipeline | Running pipelines are UNKNOWN. |
| gitlab.issue_closed | project, issue | Issue state is closed. |
| gitlab.commit_in_branch | project, branch, commit | The branch contains the commit. |
| gitlab.file_exists | project, path, ref? | Missing file vs. unreadable project are different answers. |
| gitlab.release_published | project, tag | Upcoming releases fail: scheduled is not published. |
Linear
| Type | Expected fields | Notes |
|---|---|---|
| linear.issue_completed | issue | Completed state only: cancelled is not resolved, in-progress fails. |
| linear.issue_in_state | issue, state | Matches the workflow state type or the exact state name. |
| linear.issue_assigned | issue, assignee? | Unassigned fails; assignee matches on name or email. |
Jira
| Type | Expected fields | Notes |
|---|---|---|
| jira.issue_done | issue | Done status category, whatever the workflow names it. |
| jira.issue_in_status | issue, status | Matches the status name or its category. |
| jira.issue_assigned | issue, assignee? | Unassigned fails; assignee matches on display name or email. |
Sentry
| Type | Expected fields | Notes |
|---|---|---|
| sentry.issue_resolved | issue | Resolved: ignored fails (muted is not fixed), regressions carry ISSUE_REGRESSED. |
| sentry.no_new_events_since | issue, since | Did the error actually stop? Judges by last event, not by someone clicking resolve. |
| sentry.issue_ignored | issue | Muted on purpose, distinct from resolved. |
Slack
| Type | Expected fields | Notes |
|---|---|---|
| slack.message_posted | channel, ts?, contains? | Exact ts or content scan; inaccessible channels are UNKNOWN. |
| slack.reaction_added | channel, ts, reaction?, user? | The acknowledgement signal. No reactions fails; an invisible message is UNKNOWN. |
| slack.channel_exists | name | A lookup the bot cannot perform is UNKNOWN, not an absent channel. |
| Type | Expected fields | Notes |
|---|---|---|
| email.delivered | email, to? | Provider acceptance is not delivery: sent/queued states are UNKNOWN until a terminal event. |
| email.bounced | email, to? | The inverse claim. In-flight mail is UNKNOWN: not bounced yet is not proof it will not. |
Verify your own endpoints
Every claim type above reads a provider you connected. This one reads anything with a public URL: your API, an entitlement endpoint, a health check, a customer-facing page. No connection, no credentials.
await did.verify({ type: "http.ok", expected: { url: "https://your.app/api/reports/42", status: 200, contains: "Q3" } });
The expected status is the point. A 401
is as real an outcome as a 200: after
cancelling a subscription, the claim worth checking is that the
customer is now locked out, and
{ status: 401 } proves it. Pair it with
stripe.subscription_cancelled and you
have both halves: the billing record changed, and the access
it controls actually changed with it.
A network failure is UNKNOWN, never
FAILED: the edge being unable to reach
your host is not proof your endpoint is wrong. Private and
link-local hosts are refused, so this cannot be pointed at an
internal network.
| Type | Expected fields | Notes |
|---|---|---|
| http.ok | url, status?, contains? | Synthetic: any public URL, no connection needed. Expected status can be 401: "the cancelled user is locked out" is a verifiable outcome. |
Async & webhooks
Some outcomes take time. Pass "wait": false
to get a pending verification immediately:
{
"type": "email.delivered",
"expected": { "email": "..." },
"wait": false,
"webhook_url": "https://your.app/hooks/didwork"
}
Poll GET /v1/verifications/:id, or receive
a verification.completed event at your
webhook URL. Events are signed: the
didwork-signature header is
sha256=HMAC_SHA256(webhook_secret, body),
using the webhook secret issued with your API key. Verify it before
trusting the payload.
Deliveries are durable. If your endpoint is down, DidWork retries on a backoff of 1m, 5m, 30m, 2h, then 6h before giving up.
Capability Trust
An agent tool is usually described by a name, a description, and an
input schema. That says how the tool is meant to be used, not what
it can do: update_customer might also
send an email or touch billing. Capability Trust reads the
implementation and reports what the tool can actually affect, with
evidence, so you can decide what to hand an agent before it runs.
It runs locally, reads source only, needs no API key, and uploads
nothing. Supported today: TypeScript and JavaScript projects that
register tools with @modelcontextprotocol/sdk
(server.tool,
server.registerTool) or declare them with
did.tool(). Other languages produce no
tools rather than guesses.
npx @didwork/inspect inspect # summary for the project npx @didwork/inspect tools # one row per tool: risk, confidence, status npx @didwork/inspect inspect refund_customer # evidence behind one tool
refund_customer src/tools.ts:11 Capabilities: communication.external HIGH HIGH financial.refund HIGH HIGH Evidence: lib/refund.ts:10 stripe.refunds.create() → financial.refund lib/refund.ts:16 resend.Resend.emails.send() → communication.external Status: CAPABILITY VERIFIED
Capabilities come from a small fixed taxonomy:
data.read/write/delete,
filesystem.read/write/delete,
communication.internal/external,
financial.read/write/refund/transfer,
identity.read/modify,
permission.modify,
credential.access,
code.read/write,
dependency.modify,
deployment.execute,
infrastructure.read/modify/delete,
network.read/write, and
external.execute. Each carries a fixed
risk (LOW, MODERATE, HIGH, CRITICAL) so a profile is
reproducible; where the real consequence depends on context DidWork
cannot see, the table takes the riskier reading.
Risk and confidence are separate. Confidence is the
strength of the evidence, not the severity of the capability: a tool
can be HIGH risk at LOW confidence. Anything the analysis cannot
follow (a dynamic import, eval, a
call on a value injected at runtime) is reported as a gap, and
gaps lower confidence. UNKNOWN risk means
not shown to be safe. It never degrades to LOW.
Status is verified when the
profile is established, failed with
DECLARATION_MISMATCH when the implementation
exceeds what the tool declares, and unknown
with INSUFFICIENT_EVIDENCE or
IMPLEMENTATION_UNAVAILABLE otherwise.
didwork inspect exits 1 on any mismatch, so
it can gate a pipeline; --strict also fails
on unknown, and --json emits the profiles
for agents and CI.
Declare what a tool should do and DidWork compares the declaration against the evidence:
import { did } from "@didwork/sdk"; export const refundCustomer = did.tool({ name: "refund_customer", description: "Refund an existing customer charge.", effects: ["financial.refund", "communication.external"], constraints: { maxAmount: 500 }, });
did.tool() is local and synchronous; it
attaches the declaration and rejects effects outside the taxonomy so
a typo cannot become an under-declaration. Constraints are recorded,
not enforced, in this release. Agents ask the same question through
the did_inspect_tool MCP tool
(Agents & MCP), and the tools that did a piece
of work can be attached to its verification so the receipt records
what they could affect (Tools on the receipt).
Not in scope: proving arbitrary code safe, replacing SAST or dependency scanning, sandboxing, or blocking execution. Capability Trust is a trust primitive: it tells you what a tool can do and why DidWork believes that.
Tools on the receipt
When an agent did the work, the receipt can also say what the tools
it used could affect. Pass tools
with the verification: did.tool()
definitions, profiles from didwork inspect
(@didwork/inspect), or plain records:
{
"type": "stripe.refund",
"expected": { "payment": "pi_...", "amount": 4999 },
"tools": [{
"name": "refund_customer",
"capabilities": ["financial.refund", "communication.external"],
"declared": ["financial.refund"],
"risk": "high", "confidence": "high",
"status": "failed", "reason": "DECLARATION_MISMATCH",
"source": "inspected"
}]
}
Tools are context, not evidence. DidWork records them with the
verification and shows them on the receipt under "What could the
tools affect?", labelled as caller-reported; the verdict never
depends on them. Capabilities must come from the DidWork taxonomy
(data.read … external.execute),
at most 20 tools per verification, and no file paths or symbols are
accepted, because receipts are public URLs. Retention deletes tools with the
evidence.
Watch
Some claims should stay true continuously. did.watch()
re-verifies a claim on an interval and notifies your webhook on the
first run and whenever the verdict changes: when the outcome breaks,
and when it recovers.
const watch = await did.watch({ type: "subscription.access_removed", expected: { customer: "cus_123" }, every: "10m", webhook_url: "https://your.app/hooks/didwork" });
POST /v1/watch # create (5m minimum on Pro, 1h on free) GET /v1/watches # list, with last_status and next_run_at DELETE /v1/watches/:id # stop watching
Each run is a normal verification: it appears in your log, counts
toward usage, and keeps its evidence for as long as your plan retains
it (see evidence retention). Transition events
have type watch.changed with
watch_id,
previous_status, and the full verification
in data.
Watches are plan-limited. A free key runs one watch at an hourly
cadence; the examples above use "10m",
which needs Pro. A second watch is refused with
402 watch_limit_reached, and a cadence
under an hour with
402 watch_cadence_requires_pro. Neither
refusal is counted or billed. On Pro, watches run unlimited down to
the 5m floor.
On Pro, a watch turning FAILED also emails
the key owner: the verdict, the claim, and a link to the
evidence. Alerts fire on the transition into
FAILED, not on every failing run, so a
watch that stays broken does not mail you every interval. Set a
webhook_url instead if you would rather
handle it in code; webhooks work on both plans.
Agents & MCP
@didwork/mcp exposes DidWork as
MCP tools, so any
MCP-capable agent (Claude Code, Claude Desktop, Cursor, or your
own) can call did_verify after it
acts and gate its next step on the verdict instead of grading its own
work.
No key is needed to try it: keyless, http.ok
claims verify against any public URL (rate limited, not stored), and
every other tool answers with how to unlock itself. A
free key unlocks every claim type, the log, and
watches. Creating one shows install commands with the key
already filled in.
Claude Code: install the
plugin: the
MCP server plus a session rule, a skill,
/didwork:verify and
/didwork:setup commands, and an
outcome-verifier subagent. Then run
/didwork:setup, which it proves the
connection, detects your stack, and backfills verdicts on your recent
work.
/plugin marketplace add didworksh/claude-plugin /plugin install didwork@didwork /didwork:setup
Or wire up just the MCP server:
claude mcp add didwork -e DIDWORK_API_KEY=dk_your_key -- npx -y @didwork/mcp
Cursor: install the
plugin
(MCP server, rule, skill, and /verify
command), or add the server to
~/.cursor/mcp.json. For
Claude Desktop, use
claude_desktop_config.json:
{
"mcpServers": {
"didwork": {
"command": "npx",
"args": ["-y", "@didwork/mcp"],
"env": { "DIDWORK_API_KEY": "dk_your_key" }
}
}
}
Tools: did_verify,
did_get, did_list,
did_watch, did_watches,
did_unwatch, did_usage,
and did_inspect_tool.
Same claim types and verdicts as the API; every call lands in your
log with evidence.
did_inspect_tool is
Capability Trust for agents: before calling
a project tool with real-world consequences, the agent asks what it
can actually affect and gets the capability profile with evidence.
It is local and keyless. The analyzer lives in
@didwork/inspect, an optional peer of the
server, so a bare npx -y @didwork/mcp stays
a small install and the tool answers with how to enable itself. The
plugins install both; to do the same by hand, name both packages:
npx -y -p @didwork/mcp -p @didwork/inspect didwork-mcp
Make it bite with one line in the agent's instructions
(the plugins ship this rule; bare MCP setups need it added): after
any consequential action, call
did_verify with the matching claim before
reporting success, and proceed only on
verified; treat
failed and
unknown as stop-and-escalate.
Log & usage
GET /v1/verifications?limit=20 # recent verifications for your key GET /v1/verifications/:id # one verification, evidence included GET /v1/usage # verifications performed, by month
Pricing & limits
The billing unit is a verification performed:
did.verify() calls and
did.watch() runs count the same.
- Free: 250 verifications each month, every month, no card. One watch at an hourly cadence, one API key, and 7 days of evidence.
- Pro, $9/month: 2,500 verifications included, then $0.01 each. Unlimited watches down to a 5-minute cadence, unlimited keys, a year of evidence, and an email alert whenever a watch turns
FAILED. - Every claim type, every verdict, and the full evidence of a verification are identical on both. Pro lifts what you need once something depends on the answer, never the answer itself.
Without a payment method, requests past the free allowance return
402 payment_required and watches pause:
no verification is deleted for this reason, and both resume the moment
billing is set up in the console. Refused
requests are never counted or billed.
Three other limits return 402 rather than
pausing, each naming what to do:
402 payment_required # past the monthly allowance, no card 402 watch_limit_reached # a free key already has its one watch 402 watch_cadence_requires_pro # watch interval under 1h on a free key
Creating a second API key on a free plan is refused in the console for the same reason: separate keys per environment are a Pro feature.
Evidence retention
A verification’s claim, evidence, and verdict are kept for 7 days on Free and 365 days on Pro, counted from when the verification ran. Past that the record is deleted permanently. There is no archive and no way to restore it.
This is worth planning for if you verify things you may need to prove
later. On Free, a refund verified today cannot be produced as evidence
three weeks from now. If you need the record to outlive the window,
store the response when you receive it, subscribe to Pro, or use a
webhook_url to capture each verification
into your own system as it completes. Enterprise sets a custom window.
Retention applies per verification, not per watch: stopping a watch leaves its past verifications in the log until they age out normally. Usage counts are unaffected, because a deleted verification still counted toward the month it ran in.
Connect your providers
Verifications run against your accounts. Connect
each provider in the console: keys are
validated on entry, encrypted at rest, and never shown again. Claims
for an unconnected provider return
unknown with reason
PROVIDER_NOT_CONNECTED.
DidWork reads authoritative state: it never needs write access. Execution credentials aren't evidence credentials: a key that can only cause actions can't prove them.
- Stripe: a restricted key (
rk_…) with read access to refunds, payment intents, and subscriptions is sufficient. - GitHub: a fine-grained token with read access to pull requests, issues, and actions on the repositories you verify. Public repositories work without connecting anything.
- Resend: a full-access key. Sending-only keys cannot read message state back.
- Linear: a personal API key with read access.
- GitLab: a personal access token with
read_api. - Sentry: org slug + auth token with issue read scope.
- Jira: site, account email, and API token.
- Slack: a bot token with
channels:history, invited to the channels you verify. - http.ok: no connection needed. Verify any public URL, including your own systems.
Several accounts per provider
Work across more than one GitHub org, Stripe account, or Linear
workspace? Connect each one under its own label. A claim with no
account tries every account for its
provider in connection order and answers from the first one that
can see the object; a claim that names one
("account": "acme") runs only there, and
an unknown label returns
ACCOUNT_NOT_CONNECTED listing the labels
you do have. Either way the evidence names the account that
produced the verdict. Naming the account matters most for Stripe,
where any live key answers for any id, so a refund looked up in the
wrong account reads as missing rather than as not visible.
FAQ
Why not write these checks ourselves?
The first one takes an afternoon. Then it is pagination, partial states, credential rotation, and the difference between cancelled and scheduled to cancel at period end , per provider, indefinitely. A check that lives in the codebase it grades also inherits that codebase's assumptions.
Our agent already verifies its own work. Isn't that enough?
The report and the outcome come from the same process, so a confident wrong answer is indistinguishable from a right one. DidWork gathers evidence from the provider holding the record, which is the only party in the exchange that can disagree with your software.
What access does DidWork need?
Read-only, always. Adapters gather evidence and never write, so
DidWork cannot cause the outcome it checks. See
provider permissions for the exact scope
each one needs. http.ok needs no
connection at all.
What if DidWork can't reach a provider?
The verification returns unknown, never
verified. Missing evidence is never
scored as success. See verdicts.
What does it take to adopt, or to remove?
One HTTP call. No SDK, no agent, no infrastructure, no data migration. The free allowance needs no payment method, and removing DidWork is deleting the call.
What is retained?
Each verification stores the claim, the evidence gathered, and the verdict, timestamped and readable in the console log or over the API. Credentials are encrypted at rest; API key secrets are hashed and shown once.