GitHub, GitLab, Bitbucket Cloud, Azure DevOps, and Gitea all expose REST APIs for repositories, commits, tags, and webhooks, but they disagree on nearly every detail: authentication headers, how you address a repository, how pagination works, and how webhook deliveries are verified. If you integrate more than one, you end up writing five subtly different clients for the same conceptual operations. This article walks through where the five APIs actually diverge, then shows how repo-sdk collapses those differences behind one interface.
How does authentication differ across the five providers?
Every provider wants a token, but the wire format and the token model are not the same.
- GitHub takes a personal access token or OAuth token as a
Bearercredential, and additionally supports GitHub App installation tokens — short-lived tokens minted from app credentials that carry no user identity. - GitLab has a single token shape on the wire. A personal, project, or group access token, or an OAuth token, all travel as a
Bearercredential; scopes (read_api,read_repository) decide what you can do. - Bitbucket Cloud supports two distinct schemes: an Atlassian account email plus an API token combined into HTTP
Basicauth, or a workspace/repo access token sent asBearer. These are not interchangeable — some endpoints only accept one. - Azure DevOps encodes a Personal Access Token into HTTP
Basicauth, or accepts an Entra ID bearer token from a callback you supply. It is also the only provider pinned to anorganization. - Gitea wants its personal access tokens sent with the literal
tokenscheme —Authorization: token <pat>— notBearer, which it reserves for OAuth2-issued tokens. Forgejo behaves identically.
The practical friction is that “add a token” means Basic auth here, Bearer there, and an occasional installation-token dance elsewhere. repo-sdk consumes tokens only — it never runs an OAuth flow — and handles the header encoding per provider. See Authentication for the full model.
import { createClient } from 'repo-sdk';
import { github } from 'repo-sdk/github';
import { bitbucket } from 'repo-sdk/bitbucket';
// GitHub: token becomes a Bearer credential
const gh = createClient({
provider: github({ auth: { token: process.env.GITHUB_TOKEN! } }),
});
// Bitbucket: email + API token become HTTP Basic
const bb = createClient({
provider: bitbucket({
auth: {
email: process.env.BITBUCKET_EMAIL!,
apiToken: process.env.BITBUCKET_API_TOKEN!,
},
}),
});
Which pagination styles do they use?
This is where multi-provider code gets ugly. GitHub returns a Link header with rel="next" URLs. GitLab supports both offset (page/per_page) and keyset pagination depending on the endpoint. Bitbucket returns a next field containing a full URL to the following page. Azure DevOps often returns everything in a single response, or uses a continuation-token header for large collections. Gitea uses page/limit query params with a Link header — and self-hosted instances silently clamp limit to a server-configured maximum, so you cannot assume your page size was honored.
So a naive integration has to parse Link headers for one provider, follow a next URL for another, increment a page counter for a third, and detect “there is no more” five different ways.
repo-sdk normalizes all of them into a single opaque cursor. Every list call returns { data, cursor }; when cursor is undefined, you have reached the end. You never parse a Link header or build a page URL yourself. The listAll async iterator follows cursors transparently.
// One page, whatever the provider does underneath
const { data, cursor } = await client.repos.list({ namespace: 'capawesome-team' });
if (cursor) {
const next = await client.repos.list({ namespace: 'capawesome-team', cursor });
}
// Or iterate every item, lazily
for await (const repo of client.repos.listAll({ namespace: 'capawesome-team' })) {
console.log(repo.path);
}
The cursor is deliberately opaque — it may encode a URL, an offset, or a continuation token — so treat it as a black box. There is one important caveat: Azure DevOps returns repositories in a single response with no server-side cursor, so limit is honored client-side there. That difference is documented in the provider support matrix. More on the cursor model in Pagination.
How do rate limits compare?
Rate-limiting models differ, and none of them are identical. GitHub uses a per-hour quota communicated through X-RateLimit-* response headers, plus secondary limits for abuse protection. GitLab enforces per-minute request limits (configurable on self-managed instances) and returns RateLimit-* headers. Bitbucket Cloud applies per-hour limits scoped to the authenticated account. Azure DevOps meters usage in “throughput units” and responds with Retry-After when you exceed them. Gitea is the outlier in the other direction: it has no built-in API rate limiter at all, so a 429 can only come from a reverse proxy in front of the instance.
Because the exact numbers drift and vary by plan and self-hosting, the durable rule is: read the headers, respect Retry-After, and back off. repo-sdk surfaces exhaustion as a typed RepoError and retries transient failures rather than making you special-case five header formats.
How do the repository, commit, and tag endpoints differ?
The biggest surprise for newcomers is that even addressing a repository is provider-specific:
- GitHub:
owner/name - GitLab: the full
group/.../projectpath or a numeric project id - Bitbucket:
workspace/repo_slug - Azure DevOps:
project/repository, with the organization set on the client, not in the path - Gitea:
owner/name, GitHub-style
Discovery differs too — GitHub and Gitea discover a user plus their orgs, GitLab discovers user and groups, Bitbucket discovers workspaces, and Azure DevOps discovers projects. Tags are another sharp edge: GitLab and Bitbucket return a date with each tag, while GitHub, Azure DevOps, and Gitea do not, so a tag date is simply unavailable there. Commit filtering (since, until, author) is server-side on most providers but applied client-side per page on Bitbucket — and Gitea, which supports since/until but has no author filter. And archive downloads support zip and tar.gz on four providers but zip only on Azure DevOps.
repo-sdk exposes one repo string per provider and one set of methods — client.repos, client.commits, client.tags — that return normalized shapes. Where a provider genuinely cannot do something, the client throws unsupported instead of silently returning wrong data. The full picture lives in the capability matrix.
// Same call, five providers — only the repo string format changes
const head = await client.commits.get({
repo: 'capawesome-team/repo-sdk', // owner/name on GitHub
ref: 'main',
});
console.log(head.sha, head.message);
How do the webhook models differ?
Webhooks diverge in both events and verification. GitHub, GitLab, and Gitea can emit push, tag-push, and release events; Bitbucket and Azure DevOps do not model releases at all. Azure DevOps goes further and delivers push and tag-push through a single git.push subscription, so you cannot subscribe to one without the other.
Verification is the part that bites in production. GitHub, Bitbucket, and Gitea sign deliveries with HMAC-SHA256 — though Gitea sends the digest as bare hex in X-Gitea-Signature, without GitHub’s sha256= prefix. GitLab uses a shared secret token compared against a header. Azure DevOps uses HTTP Basic auth on the receiving endpoint. Five events models, three verification schemes, and at least two signature encodings.
repo-sdk ships verifyWebhook and parseWebhookEvent per provider as standalone exports that take a Web-standard Request, so they drop into Next.js route handlers, Hono, and workers without a client.
import { verifyWebhook, parseWebhookEvent } from 'repo-sdk/github';
export async function POST(request: Request): Promise<Response> {
const valid = await verifyWebhook({ request, secret: process.env.WEBHOOK_SECRET! });
if (!valid) return new Response('invalid signature', { status: 401 });
const event = await parseWebhookEvent(request);
if (event.type === 'push') {
console.log(`push to ${event.repo} @ ${event.ref}`, event.commits);
}
return new Response('ok');
}
Summary table
| Aspect | GitHub | GitLab | Bitbucket Cloud | Azure DevOps | Gitea |
|---|---|---|---|---|---|
| Auth on the wire | Bearer (PAT/OAuth), App tokens | Bearer (PAT/project/group/OAuth) | Basic (email + API token) or Bearer | Basic (PAT) or Bearer (Entra) | token scheme (PAT) |
| Repo address | owner/name |
group/.../project or id |
workspace/repo_slug |
project/repository |
owner/name |
| Pagination | Link header |
offset or keyset | next URL field |
often single response | page/limit + Link |
| Tag dates | No | Yes | Yes | No | No |
| Self-hosted | Enterprise Server | Self-managed | Cloud only | Server | Self-hosted / Forgejo |
| Webhook events | push, tag_push, release | push, tag_push, release | push, tag_push | push, tag_push (one subscription) | push, tag_push, release |
| Webhook verification | HMAC-SHA256 | shared token | HMAC-SHA256 | basic auth | HMAC-SHA256 (bare hex) |
How does repo-sdk normalize all of this?
The pattern is: swap the provider factory, keep your code. Authentication header encoding, cursor normalization, repo-string parsing, and webhook verification all move behind one interface, and capabilities are plain data you can branch on before offering a feature. Start with the quickstart, then check the provider support and capability matrix references to see exactly what each provider supports.
if (client.capabilities.webhookEvents.includes('release')) {
// Only GitHub, GitLab, and Gitea reach here
}
FAQ
Can I use one code path for all five providers?
Mostly yes. With repo-sdk, discovery, commits, tags, pagination, and webhooks share one interface; the differences that remain — repo string format and genuinely unsupported features — are surfaced explicitly rather than hidden.
Why does Azure DevOps ignore my page size sometimes?
Its repositories endpoint returns everything in a single response with no server-side cursor, so repo-sdk applies limit client-side. Other list endpoints and providers paginate on the server.
How do I know if a provider supports a feature before calling it?
Read client.capabilities. It is plain data — for example capabilities.repoSearch or capabilities.tagDates — so you can gate UI and code paths, and the client throws unsupported instead of failing silently if you call something a provider cannot do.