Diffport

Read API · v1

Your provenance data, on a schedule you control.

A read-only HTTP API over everything Diffport knows about your repositories. Nine endpoints, one token, no write path.

Base URL https://api.diffport.com/v1

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.

Request
curl -H "Authorization: Bearer dp_a1b2c3d4e5f6..." \
     https://api.diffport.com/v1/me
200 · application/json
{
  "workspace": {
    "name": "Acme Labs",
    "slug": "acme-labs"
  },
  "repositories": 4,
  "api_version": "v1"
}
If a fresh token returns 401

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.

405 · POST /v1/pull-requests
{
  "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.

HeaderTypeMeaning
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 shape
{
  "error": {
    "status": 401,
    "message": "That token was revoked."
  }
}
200Here it is.
401No token, unknown token, malformed token, or one that has been revoked.
404No such endpoint, or no such object in this workspace.
405Anything that is not a GET.
429Over the hourly limit. Retry-After tells you when.
503The database is not reachable. Nothing was read.
404, not 403, across workspaces

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.

Paging
{
  "data": [ /* … */ ],
  "next_cursor": "41827"
}
GET/v1/me

The workspace behind the token, and how many repositories are connected. The cheapest way to check a token works.

GET/v1/repositories

Every repository in the workspace, with how many pull requests Diffport holds for each.

200 · application/json
{
  "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
    }
  ]
}
GET/v1/pull-requests

The main one. Newest first, paged.

QueryTypeNotes
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.
Request
curl -H "Authorization: Bearer $DIFFPORT_TOKEN" \
     "https://api.diffport.com/v1/pull-requests?source=ai&state=merged&limit=2"
200 · application/json
{
  "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"
}
GET/v1/pull-requests/{id}

Everything above, plus every commit and what is known about who wrote it. This is where the evidence lives.

200 · the commits array
{
  /* 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"
    }
  ]
}
confidence is null when traced is false

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.

GET/v1/agents

Every agent seen in the workspace, ordered by how much it wrote.

200 · application/json
{
  "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"
    }
  ]
}
GET/v1/alerts

Firings, newest first, each carrying the evidence that produced it rather than just a severity. Takes limit up to 100.

200 · application/json
{
  "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"
    }
  ]
}
GET/v1/reports

Issued reports with their hashes and totals. Up to 100, newest first.

200 · application/json
{
  "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"
    }
  ]
}
GET/v1/reports/{id}

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.

Verify what you were given
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
GET/v1/schema

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.

200 · application/json
{
  "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
        }
      ]
    }
  ]
}