# Link your old site to your storefront

> Keep names apart between your old site and your storefront, pass sign-ins across, and move accounts and your old forum's long threads.

Source: https://www.coritan.com/docs/organizations/storefront/old-site/

If your organization ran its own site before its Coritan storefront, Coritan can link the two while your customers move across. Your old site then checks with your storefront before it gives out a username or an email address. A person who signs in on the old site with an account that lives on your storefront arrives there, signed in with the details they typed. A customer with no services left on the old site can move their account, with its balance, saved cards and forum profile.

Every route on this page is under `https://api.coritan.com/api/v1/orgs/{org_slug}/legacy-migration`. Your old site's server calls `availability`, `relay-login`, `state` and `alerts`, and signs each request. A browser opens `go`, and your storefront calls `exchange`, `ticket` and `redeem`.

## Before you begin

- Ask [support](https://www.coritan.com/dashboard/support) to link your old site. Coritan sets up the link with a secret that you and Coritan share, gives you the secret with Coritan's API address and your organization's slug for your old site's server, then turns the link on. Until it is on, every route on this page but `state` answers `404`.
- Coritan must have your storefront's address registered, as [social sign-in](/docs/organizations/storefront/customer-sign-in/#social-sign-in) needs. `go` sends people only to that address.
- Keep the shared secret on your old site's server. Anyone who holds it can act as your old site.

## Sign each request

A request from your old site carries two headers:

`X-Legacy-Timestamp`
: When you signed it, in Unix seconds.

`X-Legacy-Signature`
: `v1=` followed by the HMAC-SHA256 of the message below, made with the shared secret and written as lower-case hex.

The message is four lines joined by a newline, with no newline at the end: the timestamp from the header, the method in capitals, the path (with `?` and the query string when the URL has one), and the SHA-256 of the raw body as lower-case hex.

```text title="The message for a request to availability"
1767225600
POST
/api/v1/orgs/acme/legacy-migration/availability
<SHA-256 of the body, as lower-case hex>
```

In Python:

```python
import hashlib, hmac, json, time

def signed_headers(secret: str, method: str, target: str, body: bytes) -> dict:
    timestamp = str(int(time.time()))
    digest = hashlib.sha256(body).hexdigest()
    message = f"{timestamp}\n{method.upper()}\n{target}\n{digest}".encode()
    mac = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return {"X-Legacy-Timestamp": timestamp, "X-Legacy-Signature": f"v1={mac}"}

body = json.dumps({"email": "alex@example.com"}).encode()
headers = signed_headers(SECRET, "POST", "/api/v1/orgs/acme/legacy-migration/availability", body)
```

Send exactly the bytes you signed, with `Content-Type: application/json`. We refuse a request whose signature does not match, or whose timestamp is more than 5 minutes from our clock. We sign our own requests to your old site the same way, and your old site should refuse one that does not check out.

## Keep names apart

Your old site asks before it registers an account, and before it changes an account's email address or username. When the answer says a value is taken, refuse it, so that nobody holds a name on one site that belongs to someone else on the other.

```bash
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/legacy-migration/availability" \
  -H "Content-Type: application/json" \
  -H "X-Legacy-Timestamp: 1767225600" \
  -H "X-Legacy-Signature: v1=..." \
  -d '{"email": "alex@example.com", "username": "alex_builds"}'
```

Send `email`, `username` or both. The answer is `{"email_taken": false, "username_taken": true}`. Both compare without regard to letter case, and a value is taken when any customer of your organization holds it, whatever that customer's status.

If your old site cannot reach us, refuse the sign-up or the change: a value we could not check may already be taken here.

## Pass sign-ins on to your storefront

When someone signs in on your old site with an account that lives on your storefront, your old site passes the sign-in to us instead of checking its own copy of the password. That covers an account your old site marks as moved, and a login your old site does not know, such as an account made on your storefront.

1. Send the sign-in to `relay-login`, with the person's IP address in `client_ip` and their browser's user agent in `user_agent`. Name the account with `login`, the email address or username as the person typed it, or with `customer_id` when your old site knows the customer's ID on your storefront. Send one of the two.

   ```bash
   curl -X POST "https://api.coritan.com/api/v1/orgs/acme/legacy-migration/relay-login" \
     -H "Content-Type: application/json" \
     -H "X-Legacy-Timestamp: 1767225600" \
     -H "X-Legacy-Signature: v1=..." \
     -d '{"login": "alex@example.com", "password": "a long passphrase", "client_ip": "203.0.113.10", "user_agent": "Mozilla/5.0"}'
   ```

2. Act on `result` in the answer:
   - `ok`: send the browser to `go_url`.
   - `invalid`: show your usual message for a wrong password. We answer the same way for a login we do not know.
   - `social_only`: the account has no password. Tell the person to sign in on your storefront, and link them to `go_url`, which opens its sign-in page.
   - `rate_limited`: too many wrong passwords. Ask the person to try again later.
3. `go_url` sends the browser to your storefront's `/auth/callback`, with `legacy_code` in the URL fragment. Your storefront posts that code to `exchange`:

   ```bash
   curl -X POST "https://api.coritan.com/api/v1/orgs/acme/legacy-migration/exchange" \
     -H "Content-Type: application/json" \
     -d '{"code": "..."}'
   ```

   The answer has the shape `POST /auth/login` answers: a session, or a two-factor step to finish as [Two-factor authentication](/docs/organizations/storefront/customer-sign-in/#two-factor-authentication) describes.

We check the password as `POST /auth/login` does, so only `active` customers can sign in this way. Wrong passwords count against the person's IP address, in the same budget as your storefront's sign-in form, and against the account: one account takes at most 10 wrong passwords in 15 minutes. The session records the person's IP address and browser, as it would if they had signed in on your storefront.

A `legacy_code` opens for 1 minute, and only for your organization. We create the session when the relay succeeds, so opening the same code twice within that minute gives the same session.

## Follow the switches Coritan sets

Coritan opens the move and freezes your old forum when you ask [support](https://www.coritan.com/dashboard/support), and your old site learns both from `state`. Ask it at most every 30 seconds, and keep the last answer for when we cannot be reached.

```bash
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/legacy-migration/state" \
  -H "Content-Type: application/json" \
  -H "X-Legacy-Timestamp: 1767225600" \
  -H "X-Legacy-Signature: v1=..." \
  -d '{}'
```

The body is an empty object, and the answer is `{"move_open": true, "forum_read_only": false}`:

- `move_open`: offer the move on your old site, and answer `check-login` with `can_move`. While it is `false`, send a customer who signs in on your storefront with an old account to your old site, signed in. It is `false` while the link is off.
- `forum_read_only`: refuse new threads, replies and edits on your old forum.

`state` answers while the link is off, so your old site learns that the move closed. It answers `404` only while no shared secret is set.

## Move an account

A customer starts the move on your old site: they confirm their password there, and your old site checks that they have no servers or subscriptions left. It then sends the browser to `go` with `to=migrate` and a *move ticket* in the fragment: `https://api.coritan.com/api/v1/orgs/{org_slug}/legacy-migration/go?to=migrate#ticket=...`. The ticket is a JWT your old site signs with the shared secret (HS256), valid for 10 minutes, naming the old account's ID (`sub`), `username`, `email`, `email_verified` and the social sign-ins it used (`providers`). `go` opens your storefront's `/migrate`.

1. Your storefront sends the ticket to `ticket`. The answer says what the move will do, before anything moves: the old `username` and `email`, and in `account` which case applies:
   - `new`: nobody here uses the email, so the move makes an account.
   - `placeholder`: the account's forum threads already moved with your forum, so the move turns their placeholder into the account.
   - `merge_automatic`: an account here has the same email and both sites verified it, so the old account joins it.
   - `merge_sign_in`: an account here has the same email but one site never verified it. The customer signs in to that account first, and your storefront sends its token with `redeem`.
   - `blocked`: the account here with that email is banned, suspended or closed.

   `handle` proposes the username for a new account or a placeholder, with `reason` when the old username could not be kept. `terms_url` and `privacy_url` link your terms and privacy policy.
2. The customer accepts your terms and, for a new account, may pick another username. Your storefront sends `redeem` with the `ticket`, `accept_terms: true` and the `handle`:

   ```bash
   curl -X POST "https://api.coritan.com/api/v1/orgs/acme/legacy-migration/redeem" \
     -H "Content-Type: application/json" \
     -d '{"ticket": "...", "accept_terms": true, "handle": "alex_builds"}'
   ```

3. We ask your old site to lock the account and send what moves, write it here in one go, then tell your old site the move is done. The answer holds the `handle`, whether the account was `merged`, `credited_usd`, the number of `cards` saved, and a `session` (or a two-factor step) shaped like the answer of `POST /auth/login`.

What moves:

- The account: the password (so the old password signs in here), the names and when the email was verified. A merge keeps the existing account's password, username and settings.
- The balance, as one wallet credit in USD: the old USD balance, USDC and USDT at face value, and SOL at our receive rate for SOL, rounded half up to the cent. The credit is not money collected, so it counts towards neither your revenue nor your payouts, and an old account is credited only once.
- Saved cards, when your old site used the Stripe account your storefront charges through and the account here has no Stripe customer of its own yet. A Stripe error does not stop the move.
- The forum profile, and the threads of a placeholder.

Services, two-factor authentication, invoices and social sign-ins stay behind. A customer who signed in with Discord connects it again here.

We record that the customer accepted your terms, email them a notice of the move, and write `customer.moved_from_legacy` to your [audit log](/docs/organizations/audit-log/). When the old balance changed after the lock, we write a `customer.legacy_alert` for your staff. When your old site does not confirm the move, your staff can ask it again from the customer's page ([Confirm a move with the old site](/docs/organizations/staff-console/customers/#confirm-a-move-with-the-old-site)). Sending the same ticket again answers the same result and credits nothing twice.

Your old site answers three signed routes for us, under its own API address:

- `POST /internal/migration/redeem` with the `ticket`: check it, lock the account and answer the account (including its password hash), the providers, the forum profile, the Stripe customer ID and the balances. While the account stays locked, answer the same again.
- `POST /internal/migration/complete` with `old_user_id`, `customer_id` and what we `credited`: mark the account moved, set its balances to zero and report any balance that differs as `drift`.
- `POST /internal/migration/check-login` with `login`, `password` and `client_ip`: for the storefront sign-in fallback in [Sign customers in to your storefront](/docs/organizations/storefront/customer-sign-in/#sign-a-customer-in). Answer `invalid`, `can_move` with a ticket, or `has_servers` with a link that signs the customer in on your old site.

## Report money you will not settle

When a payment or a deposit reaches an old account after the account moved to your storefront, or a customer disputes a charge from your old site, tell us with `alerts`. We credit and deduct nothing. We write the alert to your organization's [audit log](/docs/organizations/audit-log/) as `customer.legacy_alert`, and your staff see it on the customer's page in the [staff console](/docs/organizations/staff-console/customers/#read-a-customer-s-account), to settle by hand.

```bash
curl -X POST "https://api.coritan.com/api/v1/orgs/acme/legacy-migration/alerts" \
  -H "Content-Type: application/json" \
  -H "X-Legacy-Timestamp: 1767225600" \
  -H "X-Legacy-Signature: v1=..." \
  -d '{"customer_id": 4812, "old_user_id": 90211, "kind": "late_money", "source": "stripe", "amount_cents": 1500, "currency": "USD", "reference": "pi_3Q", "note": "Top-up paid after the move"}'
```

`customer_id` is the customer on your storefront, and `old_user_id` the account on your old site. `kind` is `late_money` or `chargeback`, and `source` is `stripe`, `paypal`, `paynow`, `crypto` or `other`. `reference` and `note` are optional.

## Move your old forum

Coritan can move your old forum into your storefront's community once. Ask [support](https://www.coritan.com/dashboard/support): Coritan freezes the old forum first, through `forum_read_only` in `state`, so nobody posts after the move. It then reads the forum from your old site over the same signed link, a page at a time.

- A thread moves when its opening post has at least 750 characters once converted to Markdown. It moves as that opening post alone, without replies, with its board, title, tags, view count, dates and its sticky and locked flags. Your forum needs a board with the same slug as the old one, or the thread is skipped.
- Before anything is written, Coritan sends you a dry run's report: the threads that qualify, the ones the moderation filter would refuse, images linked over plain `http` (your storefront shows only `https` images), and slugs that had to change. Read it before the real run.
- An author who already moved their account here is the thread's author. For anyone else, a placeholder with the status `unclaimed` holds their forum handle, avatar, banner and role, and their old username as the display name when it differs from the handle. A placeholder cannot sign in, and [Manage customers](/docs/organizations/customers/) lists what else it refuses. When that person moves their account, the placeholder becomes their account and keeps their threads.
- An `@name` in a moved post no longer links anyone, because the name may belong to someone else here now.

Old links keep working where a rule can find the new page. A thread whose slug was longer than 140 characters redirects to the slug cut to 140. A thread whose old slug ended in a hyphen, or held a run of hyphens or an underscore, redirects to the slug with those tidied into single hyphens. `/community/users/{old name}` redirects to the profile that name became when the old username was not a valid handle. Threads that did not move answer `404`.

## Result

No name is held on both sites by two different people. A person who signs in on your old site with an account on your storefront arrives on your storefront signed in, or at its two-factor step.

## Troubleshooting

`404 Not found`
: The link is off, or its secret or your old site's address is missing. From `go`, it can also mean Coritan has no storefront address registered for your organization. Ask [support](https://www.coritan.com/dashboard/support) to check the link.

`401 Invalid signature`
: The signature does not match, or the timestamp is more than 5 minutes from our clock. Sign the exact bytes you send, include `?` and the query string in the path you sign when the URL has one, and keep your server's clock right.

`422 The body does not match the contract`
: A field is missing or has the wrong type. `availability` needs `email` or `username`, and `relay-login` needs exactly one of `customer_id` and `login`.

`404 Customer not found`
: The `customer_id` you sent to `alerts` is not a customer of your organization.

`400 That sign-in link expired. Sign in again.`
: The `legacy_code` is more than a minute old, belongs to another organization, or was changed on the way. Sign in again on your old site.

`400` with `invalid_ticket`
: The move ticket was changed, is of another kind, or is more than 10 minutes old. The customer starts the move again on your old site.

`401` with `sign_in_required`
: An account here has the customer's email and one site never verified it. The customer signs in to that account, then your storefront sends `redeem` with its token.

`409` with `handle_unavailable`, `not_eligible`, `already_moved`, `email_blocked` or `limit_reached`
: The username is taken (the answer has a `suggestion`), the old account still has what `blocking` lists, the account already moved, the email belongs to a blocked account here, or your organization holds as many customers as it may.

`503` with `old_site_unavailable` or `rate_unavailable`
: Your old site did not answer, or we have no fresh SOL rate. Nothing moved; the customer tries again later.

## Related

- [Sign customers in to your storefront](/docs/organizations/storefront/customer-sign-in/)
- [Read the organization audit log](/docs/organizations/audit-log/)
- [Manage customers](/docs/organizations/customers/)

## API

- `POST /api/v1/orgs/{org_slug}/legacy-migration/availability`: Whether an email address or a username is already taken in the organization (https://www.coritan.com/docs/api/reference/organizations/org-legacy-migration/#op-post-api-v1-orgs-org-slug-legacy-migration-availability)
- `POST /api/v1/orgs/{org_slug}/legacy-migration/relay-login`: A sign-in typed on the previous platform for an account that lives here (https://www.coritan.com/docs/api/reference/organizations/org-legacy-migration/#op-post-api-v1-orgs-org-slug-legacy-migration-relay-login)
- `POST /api/v1/orgs/{org_slug}/legacy-migration/alerts`: Money or a chargeback the previous platform will not settle for a moved account (https://www.coritan.com/docs/api/reference/organizations/org-legacy-migration/#op-post-api-v1-orgs-org-slug-legacy-migration-alerts)
- `POST /api/v1/orgs/{org_slug}/legacy-migration/state`: Whether the previous platform's move is open and its forum is frozen (https://www.coritan.com/docs/api/reference/organizations/org-legacy-migration/#op-post-api-v1-orgs-org-slug-legacy-migration-state)
- `GET /api/v1/orgs/{org_slug}/legacy-migration/go`: Send the browser to the organization's storefront (https://www.coritan.com/docs/api/reference/organizations/org-legacy-migration/#op-get-api-v1-orgs-org-slug-legacy-migration-go)
- `POST /api/v1/orgs/{org_slug}/legacy-migration/exchange`: Open a relayed sign-in code and return the session it holds (https://www.coritan.com/docs/api/reference/organizations/org-legacy-migration/#op-post-api-v1-orgs-org-slug-legacy-migration-exchange)
- `POST /api/v1/orgs/{org_slug}/legacy-migration/ticket`: What moving an account from the previous website will do, before anything moves (https://www.coritan.com/docs/api/reference/organizations/org-legacy-migration/#op-post-api-v1-orgs-org-slug-legacy-migration-ticket)
- `POST /api/v1/orgs/{org_slug}/legacy-migration/redeem`: Move an account from the previous website into the organization (https://www.coritan.com/docs/api/reference/organizations/org-legacy-migration/#op-post-api-v1-orgs-org-slug-legacy-migration-redeem)
