Skip to content

API Reference

Ferran Buireu edited this page Sep 17, 2026 · 11 revisions

API Reference

ContribKit's web component exposes a small public API on Cloudflare Workers. A data response that succeeded is cached public, max-age=3600, stale-while-revalidate=86400; one that failed is no-store. Requests to /api/* are rate-limited per IP. Every response carries CSP and security headers set by the middleware.

Base URL: https://contribkit.app

No authentication. ContribKit reads only public GitHub data, with no API key, token, or OAuth. Just call the endpoints.


Endpoints

Endpoint Returns Description
GET /user/:username.svg image/svg+xml Rendered calendar; accepts palette, shape, background
GET /api/contributions?user=&year= application/json Raw Contribution Days plus yearly total
POST /api/contact application/json Sends a Contact Message to the maintainer as email
GET /api/health application/json Deployment health: env var/binding presence (never values)

GET /user/:username.svg

Renders the contribution calendar for :username as an SVG image. Always uses the latest rolling year.

Query parameters

Parameter Default Values
palette github github, catppuccin, nord, dracula, gruvbox, sunset, tokyonight, onedark, rosepine, solarized, monokai
shape rounded rounded, square, circle, dot, hex
background transparent transparent, any hex color (#101010), or a CSS color name

Unknown values silently fall back to the default, so the image never breaks.

The username must pass ContribKit's own check: alphanumeric, hyphens allowed inside, 1–39 chars. It is deliberately looser than GitHub's rule: consecutive hyphens pass here and 404 at GitHub. This endpoint always renders the latest rolling year; use /api/contributions?year= for historical data.

Example

GET /user/torvalds.svg?palette=dracula&shape=circle&background=%23101010
curl -s "https://contribkit.app/user/torvalds.svg?palette=nord&shape=hex" -o torvalds.svg

Errors

Errors return text/plain with the message:

Status Meaning
400 invalid username
404 GitHub has no such user (User not found)
429 GitHub is rate-limiting ContribKit. Not this endpoint rate-limiting you: it is never rate-limited (ADR 0010)
502 GitHub unreachable, or the page couldn't be parsed

GET /api/contributions

Returns the raw contribution data as JSON.

Query parameters

Parameter Required Description
user yes GitHub username (alphanumeric, hyphens inside, 1–39 chars; consecutive hyphens are accepted here and 404 at GitHub)
year no Integer year in 2005 … current. Omitted = latest rolling year
curl -s "https://contribkit.app/api/contributions?user=torvalds&year=2023" | jq '.total'

Response

{
  "username": "torvalds",
  "days": [
    { "date": "2024-01-01", "level": 0, "count": 0 },
    { "date": "2024-01-02", "level": 2, "count": 4 }
  ],
  "cells": [
    { "date": "2024-01-01", "level": 0, "count": 0 },
    { "date": "2024-01-02", "level": 2, "count": 4 }
  ],
  "total": 1234
}
  • days is the field to read. cells is a deprecated alias for the same array, kept so consumers written against the original shape keep working; it will be removed in a release that says so.
  • level is 0–4, the Contribution Level GitHub itself assigned the day.
  • count is the exact contribution count for that day, or null when GitHub doesn't expose a tooltip for the Cell.
  • total is the sum of every Count, or null the moment any day at level 1 or above has no Count. It is never a partial sum: a total that skipped unknown days would be a lower bound presented as a measurement. A level-0 day with no Count does not void it, because GitHub's level 0 is zero, so a year of genuine inactivity reports 0 rather than null. It is not GitHub's own headline figure, and nothing reads that.

Errors

Errors return { "error": "<message>" } with an appropriate status. A 400 carries one extra key, field, naming the parameter that was rejected (username or year), because this endpoint is consumed by code and "something was wrong" is not enough to act on:

Status Meaning
400 Missing user, or invalid username/year
404 GitHub has no such user ("User not found")
429 Two different things, and the body is what tells them apart. "Too many requests" is this endpoint's own per-IP limit, refused by the middleware before the route runs. "GitHub is rate-limiting this Worker" is upstream. Both carry Retry-After when a wait is known (a fixed 60 for ours, GitHub's own figure for theirs), and neither carries one when it is not
502 GitHub unreachable, or the page couldn't be parsed

POST /api/contact

Sends a message to the maintainer. There is no authentication and no account: the message leaves as an email through Cloudflare Email Routing and is stored nowhere, so there is nothing to read back and no id to quote.

Request body

Content-Type: application/json

{
  "name": "Ada",
  "email": "ada@example.com",
  "message": "The nord palette looks wrong at level 1."
}
Field Required Rule
email yes at most 254 characters; exactly one @, a dot in the domain, and no whitespace, <, > or ". The rule is stricter than the RFC on purpose: that address becomes a Reply-To header
message yes 10 to 4000 characters after trimming. Line breaks are kept
name no at most 80 characters. Blank or omitted is the same thing
website no a honeypot. Leave it empty. A non-empty value is accepted and not sent

Every field is trimmed before it is checked.

Responses

Status Meaning
202 Accepted. The body is { "status": "accepted" }. A submission that filled the honeypot gets this too, and sends nothing
400 Either { "error": "Invalid request body" } when the JSON does not have the shape above, or { "error": "<sentence>", "field": "name" | "email" | "message" } when a value was rejected
429 The per-IP limit for this endpoint, 5 requests a minute, which is its own bucket and far tighter than the 100/min the rest of /api/* gets. Carries Retry-After: 60
502 { "error": "Could not send your message" }. Email Routing refused the send. The reason is deliberately not in the body; it is in the server log

Every answer is Cache-Control: no-store, the 202 included.

curl -s -X POST "https://contribkit.app/api/contact" -H "Content-Type: application/json" -d '{"email":"ada@example.com","message":"hello from the API reference"}'

GET /api/health

Reports whether the deployed worker was built/configured with each expected variable and binding. Reports presence only, never values. Sent with Cache-Control: no-store.

{
  "status": "ok",
  "env": {
    "PUBLIC_GOOGLE_ANALYTICS_ID": true,
    "PUBLIC_BETTER_STACK_TRACKING_TOKEN": true,
    "API_RATE_LIMITER": true,
    "CONTACT_RATE_LIMITER": true,
    "CONTACT_EMAIL": true,
    "MAINTAINER_EMAIL": true
  },
  "timestamp": "2026-01-01T00:00:00.000Z"
}

Returns 200 when everything is present, 503 ("status": "misconfigured") otherwise.


Rate limiting

/api/* requests pass through a Cloudflare rate limiter, keyed on the caller's CF-Connecting-IP. There are two buckets: POST /api/contact uses CONTACT_RATE_LIMITER at 5 requests a minute, and every other /api/ path uses API_RATE_LIMITER at 100. The answer is the same either way:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json

{ "error": "Too many requests" }

The /user/:username.svg route is not rate-limited at the middleware level and leans on caching instead. It can still answer 429 when GitHub rate-limits the Worker, as text/plain with the same Retry-After when GitHub named one.

Retry-After is a number of seconds either way. GitHub is allowed to answer with an HTTP date instead, and the scraper converts it; anything that is neither all digits nor a parseable date is dropped rather than guessed at, so a missing header means we do not know, never retry now.


Caching

A response that carries a calendar carries:

Cache-Control: public, max-age=3600, stale-while-revalidate=86400

So a calendar is served from cache for an hour, then revalidated in the background for up to a day. README image embeds are additionally cached by GitHub's Camo proxy.

Every answer that is not a calendar says no-store: a 400, a 404, a 429 and a 500 on either endpoint, every answer /api/contact gives, and /api/health whichever answer it gives. Only the success path is cacheable, so a transient upstream failure cannot be stored by an intermediary and replayed at a reader who retries. That matters most on the SVG endpoint, whose responses reach a README through Camo: a cached failure would show a broken image that no refresh could clear.


Security headers

Every response (set by the middleware) includes:

Header Value
Content-Security-Policy strict default-src 'self' policy (allows GA + Better Stack, fonts from Google)
X-Frame-Options DENY
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy camera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policy same-origin
Cross-Origin-Resource-Policy same-origin
Cross-Origin-Embedder-Policy unsafe-none

/user/:username.svg is the one exception: it is served with Cross-Origin-Resource-Policy: cross-origin, so a browser will render it in an <img> on any site. Every other response (the pages and all of /api/*) stays same-origin. See ADR 0017.


See also

Clone this wiki locally