---
title: Onboard a repository
description: A coding agent creates one Go, PHP, Rust, Swift, Ruby, or Elixir route, waits for DNS and the certificate, and opens a pull request that rewrites import paths.
---

# Onboard a repository to Package URLs

Follow this document in order. The canonical URL is `https://packageurls.com/agents.md`. The request and response shapes are `https://packageurls.com/openapi.json`.

Package URLs publishes one hostname the customer owns. A Go hostname answers `?go-get=1` with a `go-import` meta tag. A PHP hostname, a Rust hostname, a Swift hostname, a Ruby hostname, and an Elixir hostname answer every path with `307` to the same path on the forge. The edge never proxies git. The code stays on the forge.

You will create one route per language, show the human the DNS records, wait until the certificate is active, and open one pull request per repository. Almost every organization is one language, so that is one route and two DNS records. The human does two things: sign in, and create those DNS records. You do not create DNS records, buy a hostname, or change the plan.

The control plane is `https://api.packageurls.com`. Customer hostnames are not the API. Send `Authorization: Bearer` and `Content-Type: application/json` on requests that have a body. Errors are JSON `{"message":"..."}`.

## Facts you cannot derive

- Only `go`, `php`, `rust`, `swift`, `ruby`, and `elixir` work. `POST /routes` with any other language returns `400` and `language must be go, php, rust, swift, ruby, or elixir`. The names `dart`, `haskell`, `clojure`, and `scala` are reserved CNAME aliases, not languages. Do not send them.
- One hostname covers every `owner/repo` under it. Do not create a route per repository.
- The path's first two segments are the repository: `<hostname>/<owner>/<repo>` maps to `<git_url>/<owner>/<repo>`. A third segment is not another repository. A Go `/v2` (or later) suffix stays on the module path and is not a repository segment.
- Nested GitLab groups do not map. `group/subgroup/project` is three segments. Stop and ask the human before creating a route if any repository you must publish lives at a forge path deeper than `owner/repo`.
- Paths with fewer than two segments, a segment that is not `[A-Za-z0-9\-_.~+]` or that starts or ends with `.`, an unknown host, and an unclaimed host return `404` with an empty body and `Cache-Control: no-store`.
- The edge serves a route only after the hostname is verified and `ssl_status` is `active`. Verification without an active certificate is normal. It is not a failure, and the hostname is not live yet.
- `PATCH /routes/{id}` changes the forge root (`git_url`) only. Import paths stay the same. Language and hostname are fixed at creation.
- Free is 1 route and 10,000 requests a month. Past that, the lease issuer stops resolution. The edge returns `402` and the body `Free includes 10,000 requests a month. Upgrade to Pro to keep this module resolving.`
- Pro is 25 routes and 100,000 requests a month. Max is 100 routes and 1,000,000 requests a month. On Pro and Max, requests past the allowance still resolve. Overage is never billed.
- There is no API-key endpoint. There is no `POST /account/api-keys`. Do not call Clerk device-authorization or token endpoints against this API. This API accepts a Clerk session JWT.

## 1. Credentials

This is the first human action.

1. Ask the human to open `https://packageurls.com/signin` and finish sign-in. If they have no account, they use `https://packageurls.com/signup`. Email-link sign-in is enough. A password is optional.
2. Read the session JWT from that signed-in browser on `https://packageurls.com`:

   ```js
   await window.Clerk.session.getToken()
   ```

   If `window.Clerk` is missing, open `https://packageurls.com/signin` so the sign-in widget loads Clerk, then run the same call. If you cannot run JavaScript in that browser, ask the human to paste the result of that call. The paste is part of this same sign-in, not a second action.
3. Call `GET /me` with `Authorization: Bearer <token>`.
   - `200` and `user.can_create_routes: true` means you may create a route.
   - `200` and `user.can_create_routes: false` means stop. Tell the human to verify their email at `https://packageurls.com/profile`. Creating a route would return `403` and `Verify your email before creating routes.` Do not create a route.
   - `401` and `{"message":"not signed in"}` means the token is missing, expired, or not a session JWT. Read a fresh token and retry this call. Do not create a route.
4. Session tokens expire quickly. On any later `401` with that message, read a fresh token and retry the same request once. Do not start over, and do not create a second route.

`GET /billing/status` tells you the stored plan. A live paid plan has `status` of `active`, `trialing`, or `past_due` and `plan` of `pro_month`, `pro_year`, `max_month`, or `max_year`. Any other status is Free, including `none` and `canceled`. If this call fails, continue with `GET /me` and `GET /routes`. Do not call checkout, and do not change the plan yourself. The human changes plans at `https://packageurls.com/pricing`.

## 2. Inventory and the plan

Inventory only the repositories you were given.

Count repositories whose Go module path, Composer `vcs` URL, Cargo git dependency, Swift package URL, Bundler git source, or Mix git dependency uses the old forge host you are replacing, such as `github.com/acme/widgets`. That count is how many pull requests you will open. It is not how many routes you will create.

Choose one hostname with the human. It must be a domain they control, with no scheme and no path, such as `go.example.com` or `php.example.com`. A scratch run may use a name they control on `packageurls.dev`. You still do not create the DNS records.

Choose the language from the repositories:

- Go modules only: one route, `language` `go`, CNAME target `go.packageurls.com`.
- PHP packages only: one route, `language` `php`, CNAME target `php.packageurls.com`.
- Rust crates only: one route, `language` `rust`, CNAME target `rust.packageurls.com`.
- Swift packages only: one route, `language` `swift`, CNAME target `swift.packageurls.com`.
- Ruby gems only: one route, `language` `ruby`, CNAME target `ruby.packageurls.com`.
- Elixir projects only: one route, `language` `elixir`, CNAME target `elixir.packageurls.com`. Hex registry dependencies (`{:widgets, "~> 1.0"}`) stay on hex.pm. This route is the git dependency only.
- More than one language: one hostname and one route per language. Free allows 1 route. Tell the human, and recommend Pro before the second route. If they stay on Free, create only the language they choose and stop after that route. Do not create another route hoping the cap will make an exception.

Recommend a plan from expected `go get` meta lookups, Composer fetches, Cargo git fetches, Swift package resolves, Bundler git fetches, and Mix git fetches, not from the repository count. Default to Free unless that volume clearly exceeds 10,000 requests a month. Use `GET /account/usage` when this account already serves traffic (`request_count` for the current period). A new account is at zero, so estimate from how often CI and downstream builds fetch these modules. Recommend Pro only when the estimate is clearly above 10,000 and at or under 100,000. Recommend Max only when it is clearly above 100,000. Say the recommendation and the reason. Then continue on the plan they already have. Free is the right create when the estimate is not clearly over 10,000 and they have a free route slot.

Before `POST /routes`, stop and ask the human when either of these is true:

- A repository you must publish is not exactly two forge segments (`owner/repo`). Nested GitLab groups (`group/subgroup/project`) do not map. Do not work around that with a route per repository, and do not put a path on `git_url`.
- You cannot tell which hostname they want.

## 3. Create the route

Call `GET /routes` first.

- If a route already exists for this hostname and language, use its `id`. Do not create another.
- If one exists for this hostname with the other language, stop. Language cannot be changed. Tell the human.
- If the list is already at the plan's route cap (1, 25, or 100) and none of the routes is the hostname you need, stop. The create would return `409` and `This account is already at the <plan> plan's route cap of <n>.` Point them at `https://packageurls.com/pricing`. Do not delete a route unless they say that route is abandoned.

Create when there is no matching route and the account is under its cap:

```http
POST /routes
Authorization: Bearer <token>
Content-Type: application/json

{"hostname":"go.example.com","git_url":"https://github.com","language":"go"}
```

`hostname` is a domain name. The API lowercases it and strips one trailing dot. `git_url` is a bare origin: `https://github.com`, `https://gitlab.com`, `https://codeberg.org`, or another `http` or `https` origin. No userinfo, path, query, or fragment. `language` is `go`, `php`, `rust`, `swift`, `ruby`, or `elixir`. Always send it.

A `200` body includes `id`, `hostname`, `git_url`, `language`, `cname_target`, `verified` (`false`), `verify_token`, `created_at`, and `updated_at`. `ssl_status` is absent or empty until certificate provisioning starts. Save `id`, `cname_target`, and `verify_token` from this body. Do not invent the CNAME target. Go is `go.packageurls.com`. PHP is `php.packageurls.com`. Rust is `rust.packageurls.com`. Swift is `swift.packageurls.com`. Ruby is `ruby.packageurls.com`. Elixir is `elixir.packageurls.com`.

Other create results:

- `400` `language must be go, php, rust, swift, ruby, or elixir`. You sent the wrong language. Fix the body. Do not retry with a reserved alias.
- `400` with a hostname or forge message. Show the human the message and fix the body. A forge URL with a path is rejected.
- `403` `Verify your email before creating routes.` Stop at step 1.
- `409` `That hostname is already claimed.` Stop. Ask the human for a different hostname. Do not guess.
- `409` route cap, as above. Stop.
- `401`. Refresh the token and retry this create once. If the first call returned `200`, do not create again. Use `GET /routes`.

The new route is unverified. Nothing resolves on that hostname yet.

## 4. DNS records

This is the second human action. You cannot edit their DNS.

Print these two records, using the `cname_target` and `verify_token` from the route. The TXT value is the token alone, with no name, quotes, or prefix. The whole TXT string must equal the token.

```text
go.example.com                 CNAME  go.packageurls.com
_packageurls.go.example.com    TXT    <verify_token>
```

The CNAME name is the route hostname. The TXT name is `_packageurls.` plus that hostname. Ask the human to create both records and reply when they have. Leave the route in place while they do it.

## 5. Poll verification

After you have printed the records, poll until you know which branch you are in. Wait 15 seconds, then 30, then 60, then 120, and stay at 120 seconds.

```http
POST /routes/{id}/verify
Authorization: Bearer <token>
```

The body may be empty. Read `outcome`, `retryable`, and `route`.

- `verified`. DNS ownership passed. `route.verified` is `true`. Go to step 6. Do not rewrite imports yet.
- `token-missing`. A TXT record exists at `_packageurls.<hostname>` and it is not the token. Stop the timer. Show the human the same two records again and say the TXT value does not match. Do not tell them the CNAME is the problem. Do not call `POST /routes/{id}/verify-token/rotate` unless they say the token leaked. Rotation changes the token and clears verification. When they say the TXT is fixed, resume polling.
- `dns-error`. The lookup failed. `retryable` is `true`. Keep polling. Do not tell the human the record is wrong.
- `nxdomain-or-no-txt`. No TXT yet. This is the expected state until the human publishes the record. Keep polling. `retryable` is `false` here. That flag does not mean stop. It means the lookup did not find a TXT record.
- `401`. Refresh the token and retry this verify.
- `404` `route not found`. Stop. The route is gone or it is not this account's. Do not create a replacement unless the human asks.
- `5xx`. Treat it like `dns-error` and keep polling.

`verified` is the only outcome that continues the run.

## 6. Wait for the certificate

On `verified`, the API starts certificate provisioning. The verify body already includes `route.ssl_status`. If that value is `active`, go to step 7.

Otherwise poll `GET /routes/{id}` on the same backoff. While you wait you may call `POST /routes/{id}/ssl` to refresh a certificate that is still converging. Do not call `POST /internal/ssl-sync`. That cron is not yours. It converges pending certificates without you.

Keep polling while `ssl_status` is missing, empty, `initializing`, starts with `pending`, or starts with `hold`. Pending includes `pending_validation`, `pending_issuance`, and `pending_deployment`. An empty or pending status after verification is normal. Do not fail the run, do not delete the route, and do not open the pull request.

Stop the wait, leave the route, and follow the section "If you stop" when `ssl_status` is `failed`, `not-configured`, `quota-exceeded`, or contains `timed_out`, or when it is some other value that is not `active` and not in the converging list above. The hostname is not serving.

`active` is the only status that means you may rewrite imports.

## 7. Rewrite and open the pull request

Confirm the edge before you edit. These checks talk to the customer hostname, not the API.

Go, after replacing the example names:

```sh
curl -fsS -D - "https://go.example.com/acme/widgets?go-get=1"
```

Expect `200`, `Content-Type` HTML, `Cache-Control: no-store`, and a meta tag:

```html
<meta name="go-import" content="go.example.com/acme/widgets git https://github.com/acme/widgets">
```

The prefix is `<hostname>/<owner>/<repo>`. The repo URL is `<git_url>/<owner>/<repo>`. A browser request without `?go-get=1` is a `302` to that repo URL.

PHP:

```sh
curl -fsS -o /dev/null -D - "https://php.example.com/acme/widgets"
```

Expect `307`, an empty body, `Cache-Control: no-store`, and a `Location` that ends in `/acme/widgets` on the forge. The PHP host forwards the full path, so Composer and git can follow `/info/refs` and `/git-upload-pack` onto `<git_url>/<owner>/<repo>/...`. `GET /acme/widgets?go-get=1` on a PHP host is still that `307`, not an HTML meta page. `POST /acme/widgets` on a Go host is `405` with `Allow: GET, HEAD`.

Rust uses the same redirect. After replacing the example names:

```sh
curl -fsS -o /dev/null -D - "https://rust.example.com/acme/widgets/info/refs?service=git-upload-pack"
```

Expect `307`, an empty body, `Cache-Control: no-store`, and `Location: <git_url>/acme/widgets/info/refs?service=git-upload-pack`. The query string stays. Cargo's git fetch follows that redirect and then talks to the forge.

Swift uses the same redirect. After replacing the example names:

```sh
curl -fsS -o /dev/null -D - "https://swift.example.com/acme/widgets/info/refs?service=git-upload-pack"
```

Expect `307`, an empty body, `Cache-Control: no-store`, and `Location: <git_url>/acme/widgets/info/refs?service=git-upload-pack`. The query string stays. Swift Package Manager's git clone follows that redirect and then talks to the forge.

Ruby uses the same redirect. After replacing the example names:

```sh
curl -fsS -o /dev/null -D - "https://ruby.example.com/acme/widgets/info/refs?service=git-upload-pack"
```

Expect `307`, an empty body, `Cache-Control: no-store`, and `Location: <git_url>/acme/widgets/info/refs?service=git-upload-pack`. The query string stays. Bundler's git clone follows that redirect and then talks to the forge.

Elixir uses the same redirect. After replacing the example names:

```sh
curl -fsS -o /dev/null -D - "https://elixir.example.com/acme/widgets/info/refs?service=git-upload-pack"
```

Expect `307`, an empty body, `Cache-Control: no-store`, and `Location: <git_url>/acme/widgets/info/refs?service=git-upload-pack`. The query string stays. Mix's git clone follows that redirect and then talks to the forge.

If the check is `404`, the certificate is not serving yet. Return to step 6. If it is `402`, Free has used its 10,000 requests this month. Stop the rewrite, leave the route, and tell the human to move to Pro. If it is `403` and the body is `This module is paused.`, stop and tell the human. Do not edit imports.

When the check matches, open one pull request per repository you inventoried. Do not change the repository's git remote. Consumers use the vanity host. Git still clones the forge, because the edge does not proxy git.

Go, in that repository:

- The `module` line in `go.mod`, when it is the module you are moving. This is required, not cosmetic: `go` refuses a fetched module whose declared path does not match the requested one and reports `module declares its path as ... but was required as ...`. The vanity path becomes the module's only canonical path; the old forge path no longer resolves for `go get`.
- Import strings in `.go` files, and `require` lines for that module.
- README and docs that tell someone to `go get` the old module path.
- CI scripts that `go get` or install that module path.
- Keep a `/v2` or later suffix. `github.com/acme/widgets/v2` becomes `go.example.com/acme/widgets/v2`.
- A module nested below `<owner>/<repo>` keeps its remaining path segments, for example `<hostname>/<owner>/<repo>/apps/backend`.
- Leave `github.com` URLs that are not this module: issues, actions, other organizations, and the sentence that says where the source lives.
- Set `GOPRIVATE` to the vanity hostname. It covers `GONOSUMDB` and `GONOPROXY`, so the checksum database and the public proxy never see the module path. `go env -w GOPRIVATE=<hostname>` keeps the setting for later shells and CI. Then run `go get <hostname>/<owner>/<repo>` (include `/vN` when that is the module). Default `GOPROXY` (`https://proxy.golang.org,direct`) is right when the module has public dependencies. A private forge still needs the human's existing git credentials. Package URLs does not issue those. Commit the `go.sum` update.

PHP, in the consuming repository or the one that declares the `vcs` repository:

- Change the Composer repository URL from the forge to `https://<hostname>/<owner>/<repo>`. The usual shape is `{ "type": "vcs", "url": "https://php.example.com/acme/widgets" }`.
- Change README and CI clones of that forge repository to the vanity URL.
- Leave the package `name` in `composer.json` as the forge publishes it.
- Run `composer install` when Composer is available. Otherwise run `git ls-remote https://<hostname>/<owner>/<repo>`. A private forge still needs the human's existing git credentials. Package URLs does not issue those.

Rust, in the consuming crate:

- Change the git dependency from the forge URL to `https://<hostname>/<owner>/<repo>`. The usual shape is `widgets = { git = "https://rust.example.com/acme/widgets" }`.
- Change README and CI clones of that forge repository to the vanity URL.
- Leave the package `name` in `Cargo.toml` as the forge publishes it.
- Run `cargo build` when Cargo is available. Otherwise run `git ls-remote https://<hostname>/<owner>/<repo>`. A private forge still needs the human's existing git credentials. Package URLs does not issue those.

Swift, in the consuming package:

- Change the package URL from the forge to `https://<hostname>/<owner>/<repo>`. The usual shape is `.package(url: "https://swift.example.com/acme/widgets", from: "1.0.0")`.
- Change README and CI clones of that forge repository to the vanity URL.
- Leave the package `name` in `Package.swift` as the forge publishes it.
- Run `swift package resolve` when Swift is available. Otherwise run `git ls-remote https://<hostname>/<owner>/<repo>`. A private forge still needs the human's existing git credentials. Package URLs does not issue those.

Ruby, in the consuming Gemfile:

- Change the git source from the forge URL to `https://<hostname>/<owner>/<repo>`. The usual shape is `gem "widgets", git: "https://ruby.example.com/acme/widgets"`.
- Change README and CI clones of that forge repository to the vanity URL.
- Leave the gem name in the gemspec as the forge publishes it.
- Run `bundle install` when Bundler is available. Otherwise run `git ls-remote https://<hostname>/<owner>/<repo>`. A private forge still needs the human's existing git credentials. Package URLs does not issue those.

Elixir, in the consuming `mix.exs`:

- Change the git dependency from the forge URL to `https://<hostname>/<owner>/<repo>`. The usual shape is `{:widgets, git: "https://elixir.example.com/acme/widgets"}`.
- Change README and CI clones of that forge repository to the vanity URL.
- Leave the app name in `mix.exs` as the forge publishes it.
- Run `mix deps.get` when Mix is available. Otherwise run `git ls-remote https://<hostname>/<owner>/<repo>`. A private forge still needs the human's existing git credentials. Package URLs does not issue those.
- A Hex dependency (`{:widgets, "~> 1.0"}`) stays on hex.pm. This route covers the git dependency only.

Do this on a branch. The pull request should say which hostname and route id it uses, and the output of the `go get`, Composer, `cargo build`, `swift package resolve`, `bundle install`, `mix deps.get`, or `git ls-remote` check. If that check fails after the curl check passed, fix the branch. Do not delete the route.

## If you stop

A partial run is an unverified route, or a verified route whose certificate is not `active`. Neither publishes a live hostname. If the certificate is already `active` and you stop before the pull request, the hostname is live and the imports are unchanged. Do not delete the route unless the human asks. Do not rotate the verify token. Do not open the import pull request.

Tell the human:

- `id`, `hostname`, `language`, `verified`, and `ssl_status`
- the CNAME and TXT records, when `verified` is still false
- the next call: `POST /routes/{id}/verify`, or `GET /routes/{id}` when it is already verified
- that imports were not changed and the hostname is not serving

They can resume by giving an agent this guide, the route `id`, and a fresh session token.
