Authentication
Issue a token in the app under Settings → Integrations → API. It is shown once and stored as a SHA-256 of itself, so a copy of the database hands the reader nothing usable. If you lose it, revoke it and issue another — there is no way to read it back.
Send it as a bearer token. A token belongs to exactly one workspace and can read nothing outside it.
curl -H "Authorization: Bearer dp_a1b2c3d4e5f6..." \ https://api.diffport.com/v1/me
{
"workspace": {
"name": "Acme Labs",
"slug": "acme-labs"
},
"repositories": 4,
"api_version": "v1"
}
Some shared hosts strip the Authorization header before PHP sees it. Diffport
works around it three ways before giving up. If it still happens, the fix is one rewrite
line in the API host's .htaccess — it is written out in the API package's
install notes.
Read only, by construction
Every endpoint is GET. Anything else returns 405 and says so in
the message. There is no write API and none is planned here — the app is where things
change, and this is where they are read.
{
"error": {
"status": 405,
"message": "The Diffport API is read only. Use GET."
}
}
Rate limits
600 requests an hour, per token. Every response carries the count, so you never have to guess where you are.
X-RateLimit-Limitinteger
Requests allowed in the window. Currently 600.X-RateLimit-Remaininginteger
How many are left in this window.Retry-Afterseconds
Sent only with a 429. Wait this long, then continue.The window is measured entirely on the database clock rather than by comparing two machines' idea of the time. An earlier build compared a database timestamp against the web server's clock, which meant that on a host where the two disagreed the window reset on every request and the limit never engaged at all.
Ten tokens can be live in a workspace at once, so a noisy job does not have to share a budget with a dashboard.
Errors
Every failure is JSON with the same shape, and the message is written to be read by a person who is stuck rather than by a parser.
{
"error": {
"status": 401,
"message": "That token was revoked."
}
}
Retry-After tells you when.Asking for a pull request or a report that belongs to someone else returns 404
rather than 403. A token should not be able to confirm that an id it cannot
read exists — the difference between the two answers is itself information.
Conventions
Untraced is a real value
Every source field can be ai, mixed,
human or untraced. Untraced means no signal was found. It is
not a claim that a person wrote it, and anything consuming this should treat it as its
own case rather than folding it into human. That rule is the product; it does not stop being
true because you are reading over HTTP.
Timestamps
ISO 8601 with an offset — 2026-09-01T14:22:05+00:00. A field that has no value
yet is null rather than an empty string or a zero date.
Paging
List endpoints take limit up to 100, default 50. When more rows exist,
next_cursor comes back with an id. Pass it as cursor to continue.
When it is null you have everything.
{
"data": [ /* … */ ],
"next_cursor": "41827"
}
Endpoints
Nine of them. Everything is scoped to the token's workspace without you passing an id.
/v1/meWhich workspace this token belongs to
GET/v1/repositoriesConnected repositories
GET/v1/pull-requestsProvenance and risk per pull request
GET/v1/pull-requests/{id}One pull request, with every commit
GET/v1/agentsAgents seen, with commit and line counts
GET/v1/alertsFirings, with the evidence attached
GET/v1/reportsIssued reports and their hashes
GET/v1/reports/{id}The stored report body, byte for byte
GET/v1/schemaTables and columns with authorship
The workspace behind the token, and how many repositories are connected. The cheapest way to check a token works.
Every repository in the workspace, with how many pull requests Diffport holds for each.
{
"data": [
{
"id": 14,
"full_name": "acme/platform-api",
"provider": "github",
"default_branch": "main",
"last_synced_at": "2026-09-01T18:20:11+00:00",
"pull_requests": 412
}
]
}
The main one. Newest first, paged.
repostring
Full name, such as acme/platform-api.stateenum
open, merged or closed.sourceenum
ai, mixed, human or untraced.limitinteger
1 to 100. Defaults to 50.cursorstring
The next_cursor from the previous page.curl -H "Authorization: Bearer $DIFFPORT_TOKEN" \ "https://api.diffport.com/v1/pull-requests?source=ai&state=merged&limit=2"
{
"data": [
{
"id": 41827,
"repository": "acme/platform-api",
"number": 481,
"title": "feat: multi-tenant workspace scoping",
"branch": "feat/tenant-scope",
"state": "merged",
"author": { "login": "copilot-swe-agent[bot]", "kind": "agent" },
"provenance": {
"source": "mixed",
"ai_pct": 64,
"coverage_pct": 100
},
"risk": { "band": "high", "score": 91 },
"changes": { "files": 31, "additions": 903, "deletions": 288 },
"reviews": 2,
"opened_at": "2026-08-28T09:14:00+00:00",
"first_review_at": "2026-08-28T16:02:00+00:00",
"merged_at": "2026-08-29T11:41:00+00:00",
"closed_at": null
}
],
"next_cursor": "41802"
}
Everything above, plus every commit and what is known about who wrote it. This is where the evidence lives.
{
/* every field from the list endpoint, then: */
"commits": [
{
"sha": "9f2c8a1e4b77d0c3aa51e2f8b6d4901c7e3ab5d2",
"message": "feat: scope every query to the workspace",
"author": { "login": "copilot[bot]", "email": "copilot@users.noreply.github.com" },
"source": "ai",
"confidence": "high",
"agent": "github-copilot",
"traced": true,
"lines": { "added": 184, "removed": 31, "ai": 184, "counted": 184 },
"files_changed": 7,
"committed_at": "2026-08-28T10:03:00+00:00",
"signature": "U"
}
]
}
A commit with no signal carries no confidence, because there is nothing to be confident about. It is not reported as low — it is reported as absent.
Every agent seen in the workspace, ordered by how much it wrote.
{
"data": [
{
"key": "github-copilot",
"tool": "GitHub Copilot",
"model": "gpt-5",
"commits": 418,
"ai_commits": 402,
"ai_lines": 51204,
"last_seen_at": "2026-09-01T17:55:00+00:00"
}
]
}
Firings, newest first, each carrying the evidence that produced it rather than just a
severity. Takes limit up to 100.
{
"data": [
{
"id": 903,
"rule": "Destructive schema change",
"severity": "high",
"title": "Destructive DDL shipped by an agent",
"state": "open",
"evidence": [
{ "field": "destructive_count", "actual": 1, "op": ">", "expected": 0 }
],
"pull_request": { "repository": "acme/platform-api", "number": 481 },
"fired_at": "2026-08-29T11:43:00+00:00"
}
]
}
Issued reports with their hashes and totals. Up to 100, newest first.
{
"data": [
{
"id": 88,
"title": "Q3 provenance attestation",
"repository": "acme/platform-api",
"scope": "repository",
"format": "json",
"sha256": "b09918a9b632fc906f10d7e960e83b8d…",
"bytes": 18442,
"commits": 397,
"traced": 381,
"ai_pct": 62,
"coverage_pct": 96,
"period": { "from": "2026-06-03T00:00:00+00:00", "to": "2026-09-01T00:00:00+00:00" },
"generated_at": "2026-09-01T07:00:00+00:00"
}
]
}
The stored body, byte for byte, as application/json or text/csv
depending on how it was issued. Not JSON-wrapped — this is the file itself.
The response carries X-Diffport-SHA256. Hash what you received and compare: if
they match, nothing has been altered since the report was issued. That is the whole point of
a signed attestation, and it is checkable in one line.
curl -sD headers.txt -H "Authorization: Bearer $DIFFPORT_TOKEN" \ https://api.diffport.com/v1/reports/88 -o report.json shasum -a 256 report.json grep -i x-diffport-sha256 headers.txt # the two should agree
Tables and columns with authorship per column, rebuilt from the migrations in your pull requests. Diffport never connects to your database, so this is what your migrations say rather than what your server holds.
{
"data": [
{
"table": "audit_log",
"repository": "acme/platform-api",
"inferred": false,
"authorship": { "ai_columns": 3, "human_columns": 1, "untraced_columns": 1 },
"columns": [
{
"name": "payload",
"type": "json",
"nullable": true,
"source": "ai",
"altered": false
}
]
}
]
}