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

TypeExpected fieldsNotes
stripe.refundpayment, amount?, currency?Amounts in minor units. Detects wrong amounts, duplicates, pending and failed refunds.
stripe.subscription_cancelledsubscriptionScheduled-at-period-end is FAILED: the subscription is still active.
stripe.payment_succeededpayment, amount?, currency?Checks amount_received, not intent.
stripe.invoice_paidinvoice, amount?Open invoices are UNKNOWN (may still settle); void/uncollectible fail.
stripe.subscription_activesubscription, price?, quantity?Catches plan changes that never applied; past_due fails.
stripe.checkout_completedsession, amount?, currency?An open session is UNKNOWN, because the customer may still be paying. Expired fails; so does complete-but-unpaid.
stripe.payout_paidpayout, amount?, currency?pending and in_transit are UNKNOWN: the money is still moving.
stripe.payment_method_attachedpayment_method, customer, last4?Is the card actually on file for that customer, not merely created.

GitHub

TypeExpected fieldsNotes
github.pr_mergedrepository, pull_request, commit?Closed-without-merge is FAILED. Optional merge-commit assertion.
github.workflow_passedrepository, run, commit?Queued or in-progress runs are UNKNOWN, not failed.
github.issue_closedrepository, issueA number that points at a PR fails with NOT_AN_ISSUE.
github.release_publishedrepository, tagDrafts fail: published means published.
github.commit_in_branchrepository, branch, commitDeploy check: the SHA is an ancestor of the branch head.
github.file_existsrepository, path, ref?Artifact check; distinguishes a missing file from an invisible repo.
github.deployment_succeededrepository, environment, commit?In-progress deploys are UNKNOWN. The commit assertion catches a green deploy of the wrong revision.
github.pr_review_approvedrepository, pull_request, reviewer?Latest review per reviewer wins: an approval later withdrawn is not an approval.
github.branch_existsrepository, branch, commit?Distinguishes a missing branch from an invisible repo; optional head assertion.

GitLab

TypeExpected fieldsNotes
gitlab.mr_mergedproject, merge_requestMerged, not merely closed.
gitlab.pipeline_passedproject, pipelineRunning pipelines are UNKNOWN.
gitlab.issue_closedproject, issueIssue state is closed.
gitlab.commit_in_branchproject, branch, commitThe branch contains the commit.
gitlab.file_existsproject, path, ref?Missing file vs. unreadable project are different answers.
gitlab.release_publishedproject, tagUpcoming releases fail: scheduled is not published.

Linear

TypeExpected fieldsNotes
linear.issue_completedissueCompleted state only: cancelled is not resolved, in-progress fails.
linear.issue_in_stateissue, stateMatches the workflow state type or the exact state name.
linear.issue_assignedissue, assignee?Unassigned fails; assignee matches on name or email.

Jira

TypeExpected fieldsNotes
jira.issue_doneissueDone status category, whatever the workflow names it.
jira.issue_in_statusissue, statusMatches the status name or its category.
jira.issue_assignedissue, assignee?Unassigned fails; assignee matches on display name or email.

Sentry

TypeExpected fieldsNotes
sentry.issue_resolvedissueResolved: ignored fails (muted is not fixed), regressions carry ISSUE_REGRESSED.
sentry.no_new_events_sinceissue, sinceDid the error actually stop? Judges by last event, not by someone clicking resolve.
sentry.issue_ignoredissueMuted on purpose, distinct from resolved.

Slack

TypeExpected fieldsNotes
slack.message_postedchannel, ts?, contains?Exact ts or content scan; inaccessible channels are UNKNOWN.
slack.reaction_addedchannel, ts, reaction?, user?The acknowledgement signal. No reactions fails; an invisible message is UNKNOWN.
slack.channel_existsnameA lookup the bot cannot perform is UNKNOWN, not an absent channel.

Email

TypeExpected fieldsNotes
email.deliveredemail, to?Provider acceptance is not delivery: sent/queued states are UNKNOWN until a terminal event.
email.bouncedemail, 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.

TypeExpected fieldsNotes
http.okurl, 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.