REST API
Overview
The GitVelocity v1 REST API exposes endpoints under /api/v1/ so any HTTPS client can pull your velocity data, and update how contributors appear in reports. Authentication uses Personal Access Tokens (PATs) minted from your organization's settings page.
Everything is readable with a default token. The one write endpoint requires a token minted with the write scope — see Tokens.
Quick start
- Open Settings → API Tokens in the dashboard.
- Click Create token, give it a name, pick an expiry (90 days, 1 year, or never), choose Read only or Read and write, and copy the value that appears in the modal. You will only see it once.
- Use the token as a Bearer credential against any of the endpoints below.
The token table is the whole UI: Create token opens a modal where you name the token and pick an expiry; the value is shown once after creation, then only the metadata stays here. Revoke via the trash icon takes effect immediately.
curl -H "Authorization: Bearer gv_pat_..." \
"https://api.gitvelocity.dev/api/v1/pull-requests?limit=50"
Endpoints
| Method | Path | Returns |
|---|---|---|
GET |
/api/v1/pull-requests |
Pull requests with metadata. JSON list with cursor pagination, or CSV via Accept: text/csv. |
GET |
/api/v1/pull-requests/{id} |
Single PR with the full six-dimension score breakdown. |
GET |
/api/v1/contributors |
List of contributors in the organization, cursor-paginated. |
GET |
/api/v1/contributors/{username} |
Single contributor with aggregated current-state scores. |
GET |
/api/v1/contributors/{username}/trends |
Per-contributor time series, bucketed by day / week / month / quarter. |
PATCH |
/api/v1/contributors/{username} |
Update display name, chart color, or visibility. Requires the write scope. |
GET |
/api/v1/trends/contributors |
Fleet-wide trend matrix — every contributor's scores over time. |
GET |
/api/v1/organization |
The org the token is currently scoped to (id, name, slug, plan). |
All response keys are snake_case (contributor_username, external_id, next_cursor).
Tokens
- Tokens are scoped to exactly one organization at mint time. You cannot use a token to read data from a different organization.
- Tokens carry one of two scope sets, chosen at mint time:
read(the default) — everyGETendpoint below.read write— additionally allowsPATCH /api/v1/contributors/{username}.
- Existing tokens are unaffected. Every token minted before the
writescope existed carriesread, and keeps exactly the meaning it had when it was created. A read-only token calling a write endpoint gets403witherror: "insufficient_scope"and aWWW-Authenticateheader naming the scope it needs. To write, mint a new token and choose Read and write. - Scope is not the same as role. Scope limits what the token can do; your organization role limits what you can do. Both apply. Changing a contributor's
suspendedflag needs anowneroradminrole and awritetoken — awritetoken held by a member still cannot hide anyone. - Other write surfaces (achievement reactions, benchmark annotations, manual rescores) remain dashboard-only and reject PATs with
403. - Tokens expire on the schedule you choose: 90 days, 1 year (recommended default), or never. Mint a new one to rotate when an expiring token reaches its cutoff. A "Never expires" token never auto-expires but can still be revoked manually.
- Tokens can be revoked from Settings → API Tokens. Revocation takes effect immediately.
- The raw token is shown once at mint time and is never persisted server-side in plaintext — only a SHA-256 hash is stored.
Endpoint reference
GET /api/v1/pull-requests
Pull requests with metadata. Default response is JSON with cursor pagination; Accept: text/csv returns CSV.
Query parameters (all optional):
| Param | Type | Description |
|---|---|---|
repos |
string | Comma-separated repo filter, e.g. acme/api,acme/web. |
authors |
string | Comma-separated author login filter. |
merged_after |
ISO date | Only return PRs merged on or after this date. |
merged_before |
ISO date | Only return PRs merged on or before this date. |
min_score |
number | Only return PRs with a score >= this value. |
max_score |
number | Only return PRs with a score <= this value. |
search |
string | Free-text search across PR title and description. |
days |
integer | Convenience window — last N days. Ignored when merged_after / merged_before are set. |
cursor |
string | Opaque pagination token from the previous response's next_cursor. Omit on the first page. |
limit |
integer | Page size (default 50, max 200; values above 200 are clamped silently). |
fields |
string | Comma-separated list of fields to include, e.g. id,title,score. Optional — full payload is the default. |
format |
string | csv to force the CSV branch from clients that can't set Accept. See content negotiation. |
JSON response shape (Accept: application/json, the default):
{
"items": [
{
"id": "12345",
"repo": "acme/api",
"external_id": 87,
"title": "Add throttle to PAT endpoints",
"html_url": "https://github.com/acme/api/pull/87",
"score": 91,
"contributor_username": "alice",
"contributor_display_name": "Alice Example",
"state": "closed",
"merged": true,
"merged_at": "2026-05-01T17:24:00.000Z",
"updated_at": "2026-05-01T17:24:00.000Z",
"closed_at": null,
"source_type": "pr"
}
],
"next_cursor": "eyJtZXJnZWRfYXQiOiIyMDI2LTA1LTAxVDE3OjI0OjAwLjAwMFoiLCJzY29yZV9pZCI6MTIzNDV9"
}
next_cursor is null on the last page. To fetch the next page, pass it back verbatim as ?cursor=.... The cursor is opaque — don't try to parse it; the format is allowed to change without notice.
CSV response (Accept: text/csv or ?format=csv):
HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="gitvelocity-pull-requests-2026-05-15.csv"
Repository,Type,Reference,Title,Author Login,...
acme/api,pr,87,Add throttle to PAT endpoints,alice,...
The CSV columns match the dashboard's Activity export exactly.
A text value that would otherwise begin =, +, -, @, a tab or a carriage return is prefixed
with a single apostrophe. Spreadsheets read a cell starting with any of those as a formula and
evaluate it, and they strip the surrounding quotes before deciding — so RFC 4180 quoting alone does
not prevent it. Numbers are exempt, so score columns keep their signs: -5 stays -5.
If you parse the CSV programmatically rather than opening it in a spreadsheet, strip a single leading apostrophe from text fields to recover the stored value.
GET /api/v1/pull-requests/{id}
Single PR with the full six-dimension score breakdown. id is the opaque identifier returned by the list endpoint.
The detail endpoint is JSON-only — Accept: text/csv returns 406 Not Acceptable.
Example response:
{
"id": "12345",
"repo": "acme/api",
"external_id": 87,
"title": "Add throttle to PAT endpoints",
"score": 91,
"contributor_username": "alice",
"rubric": {
"scope": { "score": 18, "max_score": 20, "factors": "..." },
"architecture": { "score": 19, "max_score": 20, "factors": "..." },
"implementation": { "score": 18, "max_score": 20, "factors": "..." },
"risk": { "score": 17, "max_score": 20, "factors": "..." },
"quality": { "score": 14, "max_score": 15, "factors": "..." },
"perf_security": { "score": 5, "max_score": 5, "factors": "..." }
},
"schema_version": "1.0",
"created_at": "2026-05-01T17:24:30.000Z"
}
GET /api/v1/contributors
Cursor-paginated list of contributors. Same cursor / limit semantics as /pull-requests.
{
"items": [
{
"username": "alice",
"display_name": "Alice Example",
"profile_url": "https://github.com/alice",
"avatar_url": "https://avatars.example.com/alice.png",
"is_bot": false,
"first_seen_at": "2025-11-04T12:00:00.000Z",
"last_seen_at": "2026-05-12T09:15:00.000Z",
"display_name_override": null,
"suspended": false,
"color_override": null,
"merged_into": null
}
],
"next_cursor": null
}
display_name is what to show: the admin-set override if there is one, else the
provider-sourced name. display_name_override is the override alone, and is
null when none is set. Both are present because they answer different
questions — after clearing an override that happened to match the provider name,
display_name is unchanged, and only display_name_override shows the clear
landed. A reconcile script should compare against display_name_override.
merged_into is non-null when this account has been merged into another
person — a duplicate identity whose work now reports through the canonical row.
Writes to a merged row still apply and still persist; they just change a record
that reports elsewhere. Filter on this before a bulk rename rather than spending
a request per ghost.
first_seen_at is the earliest authored work we hold a date for — the earliest
scored pull request or commit, or the earliest pull request we have ingested,
whichever is earlier. It is null when we hold no dated work for that
contributor, which is a real answer rather than an error: read it as "unknown",
not as "joined just now".
GET /api/v1/contributors/{username}
Single contributor with current-state aggregated scores. 404 on unknown username.
Optional ?days=N controls the lookback window (default: all-time).
{
"username": "alice",
"display_name": "Alice Example",
"profile_url": "https://github.com/alice",
"avatar_url": null,
"display_name_override": null,
"suspended": false,
"color_override": null,
"merged_into": null,
"repositories": ["acme/api", "acme/web"],
"current_scores": {
"average_score": 87,
"total_prs": 30,
"max_score": 100,
"min_score": 50,
"score_distribution": { "high": 20, "medium": 8, "low": 2 }
}
}
PATCH /api/v1/contributors/{username}
Update how a contributor appears. Requires a token with the write scope.
Returns the updated contributor in the same shape as the list endpoint, so a
write and a read cannot disagree.
curl -X PATCH \
-H "Authorization: Bearer gv_pat_..." \
-H "Content-Type: application/json" \
-d '{"display_name_override": "Alice Example", "color_override": "#4F46E5"}' \
"https://api.gitvelocity.dev/api/v1/contributors/alice"
The body is a subset of the representation. Every writable key is a field
you can read back from GET /api/v1/contributors/{username}, spelled the same
way. There are only three:
| Field | Type | Notes |
|---|---|---|
display_name_override |
string | null |
Sets the display override. null — or an all-whitespace string — clears it. Max 255 characters; may not contain control characters or angle brackets. |
color_override |
string | null |
Exactly #rrggbb. null clears it and reverts to the generated color. |
suspended |
boolean |
true hides the contributor from reports and metrics. Owner or admin only. |
display_name is not writable. It is the rendered name — the override if
there is one, otherwise the provider-sourced name — so there is nothing to set.
Sending it is a 400. Write display_name_override instead, and read
display_name to see the result.
Presence semantics. A field is read by whether its key is present, not by
its value. An omitted key leaves the stored value alone; an explicit null
clears it. {} is a valid body meaning "change nothing" and returns the
contributor unmodified. Sending {"display_name_override": null} is therefore a
very different request from sending {}.
All or nothing, up to the first write. Every field is validated before any
of them is applied, so a body rejected with 400 or 403 stores nothing — a
member sending {"display_name_override": "...", "suspended": true} gets 403
and neither field is written.
That guarantee covers validation and permission failures, which is every failure
you can cause from the client side. It does not extend past the first
successful write: the fields are applied in sequence without a transaction, so a
404 or 503 raised part-way through a multi-field request can leave earlier
fields applied. Re-read the contributor to see what landed.
Unknown fields are rejected, not ignored. A misspelled key is a 400 naming
the offender, rather than a silent success that changed nothing. growth_goal_pct
is deliberately not accepted here — it is edited on the contributor profile, not
in Team Display. Merging and unmerging contributors stay dashboard-only.
Merged contributors accept writes. A PATCH to a row whose merged_into
is non-null succeeds and persists — that row's work simply reports through its
canonical counterpart, so the change may not surface where you expect. The
response carries merged_into so you can tell.
404 when {username} matches no contributor in the token's organization.
A contributor in a different organization is also a 404 — the API never
confirms that a row exists in another tenant.
Rate limits are per endpoint. This PATCH has its own 60/minute and
10,000/day budget and does not draw down the budget of the GET routes, so a
reconcile can read and write in the same loop without the two competing. At 60
writes per minute, updating 300 contributors takes about five minutes; spread
the calls rather than bursting.
GET /api/v1/contributors/{username}/trends
Per-contributor time series.
| Param | Type | Description |
|---|---|---|
interval |
string | day | week | month | quarter. Default week. |
periods |
integer | How many intervals to return (default 12, max 52 — values above 52 are clamped silently). |
{
"username": "alice",
"display_name": "Alice Example",
"interval": "week",
"periods": ["2026-W17", "2026-W18", "2026-W19"],
"points": [
{ "period": "2026-W17", "average_score": 82, "total_score": 820 },
{ "period": "2026-W18", "average_score": 85, "total_score": 850 },
{ "period": "2026-W19", "average_score": 87, "total_score": 870 }
]
}
GET /api/v1/trends/contributors
Fleet-wide trend matrix. Same interval / periods parameters as the per-contributor endpoint, plus cursor pagination over contributors (cursor, limit).
{
"interval": "week",
"periods": ["2026-W17", "2026-W18", "2026-W19"],
"contributors": [
{
"username": "alice",
"display_name": "Alice Example",
"points": [
{ "period": "2026-W17", "average_score": 82, "total_score": 820 },
{ "period": "2026-W18", "average_score": 85, "total_score": 850 },
{ "period": "2026-W19", "average_score": 87, "total_score": 870 }
]
}
],
"next_cursor": null
}
GET /api/v1/organization
Returns the organization the token is currently scoped to. Useful for debugging "what am I authed against?" without round-tripping the Settings UI.
{
"id": "11111111-2222-3333-4444-555555555555",
"slug": "acme",
"name": "Acme Co",
"plan": null
}
plan is reserved for future use and is always null today.
Cursor pagination
All list endpoints (/pull-requests, /contributors, /trends/contributors) return next_cursor: string | null. Pass the value back as ?cursor=... to fetch the next page. null indicates the last page.
The cursor is opaque — do not assume any structure. We may change the encoding without notice; clients that pass it back verbatim will continue to work, clients that try to parse it may break.
Inserts during pagination don't cause skipped or duplicated rows, because the cursor encodes the ordering key of the last seen row.
Field selection
?fields=id,title,score is accepted on every list endpoint. The current release returns the full DTO regardless; the parameter is reserved so clients can opt in to partial responses when we wire field projection in a future release. Adopt the parameter now if you want to be forward-compatible.
Status codes
| Status | When you'll see it |
|---|---|
200 |
Successful read, or a successful PATCH. |
400 |
Malformed PATCH body: a non-object, an unknown field (named in the message), a wrongly-typed value, or a display_name_override that is over 255 characters or contains control characters or angle brackets. Nothing is written — see the all-or-nothing rule. |
401 |
Token missing, malformed, expired, or revoked. Check WWW-Authenticate header. |
403 |
Token lacks a required scope (error: "insufficient_scope" — a read-only token on the PATCH), your role is too low for a field you sent (error: "insufficient_role" — a member sending suspended), the user who minted the token has lost membership in the token's organization (error: "membership_revoked"), or you hit a non-v1 endpoint with a PAT. |
404 |
Unknown id / username on a detail endpoint, or the org the token was minted for has been deleted. |
406 |
You sent Accept: text/csv to a detail endpoint. CSV is only supported on the list endpoint. |
429 |
Rate limit exceeded. Honor the Retry-After header (or Retry-After-minute / Retry-After-day for granular per-window values) and try again. |
503 |
A feature the request needs is temporarily unavailable (for example, chart colors before their migration has run). Distinct from 5xx: retrying the identical request will not help until we act. |
5xx |
GitVelocity is having a bad day. Retry with exponential backoff. |
Rate limits
PAT-authenticated requests share two budgets per organization:
- 60 requests per minute
- 10,000 requests per day
If you exceed either, the API responds 429 Too Many Requests. The CSV mode is designed to return everything in one call — prefer it over paginating /pull-requests if you can fit the result in memory.
A 429 response looks like:
{
"statusCode": 429,
"message": "ThrottlerException: Too Many Requests"
}
with a numeric Retry-After header giving the seconds to wait. For finer-grained back-off control, two per-window headers ship alongside it: Retry-After-minute (seconds until the 60 / minute window resets) and Retry-After-day (seconds until the 10,000 / day window resets). The unsuffixed Retry-After mirrors Retry-After-minute, since the minute window resets first.
Example: load the CSV into Snowflake
A minimal pattern for one common destination — adapt freely for BigQuery, Redshift, DuckDB, or anywhere else you can ingest CSV:
#!/usr/bin/env bash
# Download CSV and upload to S3 for COPY INTO.
set -euo pipefail
TOKEN="$GITVELOCITY_PAT"
DATE="$(date -u +%Y-%m-%d)"
curl -fsSL \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: text/csv" \
"https://api.gitvelocity.dev/api/v1/pull-requests?days=7" \
| aws s3 cp - "s3://my-bucket/gitvelocity/${DATE}.csv"
-- Snowflake side: load via COPY INTO.
CREATE OR REPLACE TABLE raw_gitvelocity_prs (
organization VARCHAR,
repo VARCHAR,
pr_number NUMBER,
title VARCHAR,
author_login VARCHAR,
created_at TIMESTAMP_TZ,
merged_at TIMESTAMP_TZ,
score NUMBER,
-- ...add the rest of the columns you care about.
raw_csv VARIANT
);
COPY INTO raw_gitvelocity_prs
FROM @my_s3_stage/gitvelocity/
FILE_FORMAT = (TYPE = CSV SKIP_HEADER = 1 FIELD_OPTIONALLY_ENCLOSED_BY = '"');
Known limits
A few things to plan around:
- Rate limits are per endpoint per org, not a shared budget. The 60 / minute and 10,000 / day quotas apply to each endpoint independently. A client hitting
/api/v1/pull-requestscannot 429 another client hitting/api/v1/contributors, but two clients hitting the same endpoint share that endpoint's bucket. - No per-token fairness within an org. Multiple PATs in the same org draw from the same per-endpoint bucket — there is no per-token sub-allocation. If one job goes hot, it can starve another on the same endpoint.
- PATs with a finite expiry don't auto-refresh. Rotate them via Settings → API Tokens before they expire. The API will start returning
401 invalid_tokenthe moment a token crosses its expiry. Pick "Never expires" if your platform can't accommodate periodic rotation. ?fields=is accepted but not yet honored. The current implementation always returns the full DTO. Reserved for a future release.
Stability
- New optional fields and new endpoints may appear at any time and are not considered breaking changes.
- We will give 30 days notice to every active token owner before any breaking change to a documented field or endpoint under
/api/v1/.
If anything is unclear, missing, or broken, reach out via the in-app chat or support@headline.com.