Repository navigation
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.
| 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) |
Renders the contribution calendar for :username as an SVG image. Always uses the latest rolling year.
| 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.
GET /user/torvalds.svg?palette=dracula&shape=circle&background=%23101010
curl -s "https://contribkit.app/user/torvalds.svg?palette=nord&shape=hex" -o torvalds.svgErrors 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, GitHub answered another status, or the page couldn't be parsed |
500 |
anything thrown inside the route, logged; the body is Something went wrong. Please try again.
|
Every one of them is no-store.
Returns the raw contribution data as JSON.
| 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'{
"username": "torvalds",
"days": [
{ "date": "2024-01-01", "level": 0, "count": 0 },
{ "date": "2024-01-02", "level": 2, "count": 4 }
],
"total": 1234
}- The body carries exactly these three keys.
daysholds one entry per Contribution Day. -
levelis0–4, the Contribution Level GitHub itself assigned the day. -
countis the exact contribution count for that day, ornullwhen GitHub doesn't expose a tooltip for the Cell. -
totalis the sum of every Count, ornullthe 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 reports0rather thannull. It is not GitHub's own headline figure, and nothing reads that.
Errors return { "error": "<message>", "kind": "<kind>" } with an appropriate status, because this endpoint is
consumed by code and "something was wrong" is not enough to act on. kind names the failure, one of InvalidInput,
NotFound, RateLimited, Network, Upstream and Parse, so a client can word it without parsing error. A 400 carries
one extra key, field, naming the parameter that was rejected (username or year). Two answers carry no kind,
because no failure of the lookup produced them: this endpoint's own per-IP 429 and the 500 an unexpected error
gets.
| Status | kind |
Meaning |
|---|---|---|
400 |
InvalidInput |
Missing user, or invalid username/year |
404 |
NotFound |
GitHub has no such user ("User not found") |
429 |
RateLimited, or none |
Two different things, and kind is what tells them apart. No kind ("Too many requests") is this endpoint's own per-IP limit, refused by the middleware before the route runs. RateLimited ("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 |
Network, Upstream or Parse
|
Network: GitHub unreachable or too slow ("Could not reach GitHub"). Upstream: GitHub answered with a status other than 200, 404 and 429 ("GitHub returned 503"). Parse: GitHub answered, and the page held no Contribution Day |
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.
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.
| 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 |
500 |
{ "error": "Something went wrong. Please try again." }. The deployment has no CONTACT_EMAIL binding, which the repository throws on rather than calling a refusal, or something else threw inside the route; either way it is logged, and /api/health says whether the binding is there |
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"}'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.
/api/* requests pass through a Cloudflare rate limiter, keyed on the caller's CF-Connecting-IP. There are two buckets, chosen by path alone: /api/contact, whatever the method, 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
Cache-Control: no-store
{ "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.
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.
Behind both endpoints, the Worker's own request to GitHub is cached at the edge, with cf.cacheTtl set to an hour: every request for the same Username and Year within that hour is served from one GitHub fetch, whatever query string it carried. That cache shows in no response header, and nothing here sets cacheTtlByStatus, so which of GitHub's error answers Cloudflare keeps for the hour is Cloudflare's rule rather than this code's. The landing page is never public: it is private, max-age=3600, stale-while-revalidate=86400 when the visitor named someone and the calendar loaded, and private, no-store otherwise, because it can depend on the visitor's cookie. How It Works draws the three layers.
The contact and legal pages keep a minute in the browser: /contact, /legal-notice, /privacy and /terms answer public, max-age=60, so a click on a footer link uses the page its hover prefetched.
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.
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 |
Static files never reach the middleware. Workers Assets answers /robots.txt, /og.png, /.well-known/security.txt and the built assets before the Worker runs, so they carry only the X-Content-Type-Options, Referrer-Policy and X-Frame-Options that public/_headers sets, and security.txt is served as text/plain.
/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.
- How It Works walks through what happens behind each request.
-
SVG Rendering covers how
palette/shape/backgroundare applied. - Web Application covers middleware, env vars, and deploys.