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.

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.

Verify

POST https://api.didwork.sh/v1/verify

{
  "type": "stripe.refund",
  "expected": { "payment": "pi_123", "amount": 4999 },
  "subject": "order_18427"   // optional caller-side reference
}

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 — 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 — with no connection and 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.

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.

# Claude Code
claude mcp add didwork -e DIDWORK_API_KEY=dk_your_key -- npx -y @didwork/mcp

Cursor (~/.cursor/mcp.json) or Claude Desktop (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. Same claim types and verdicts as the API; every call lands in your log with evidence.

Make it bite with one line in the agent's instructions: after any consequential action, call did_verify with the matching claim before reporting success — 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: 1,500 verifications each month, every month, no card. One watch at an hourly cadence, one API key, and 7 days of evidence.
  • Pro — $25/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 — a deleted verification still counted toward the month it ran in.

Connect your providers

Verifications run against your accounts. Connect each provider once 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.

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.

DidWork: verification infrastructure for software outcomes