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
| 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 — 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 — 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.
| 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.
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.