# Adding MeshHub signup to your website

This page is for the person building a signup form on a marketing site. It
covers one endpoint: the one that creates a MeshHub workspace and drops the
visitor into their dashboard already signed in.

You do **not** need an API key to call it, and you do **not** need any CORS
setup on our side — the endpoint is open to any origin.

> Building *on* the MeshHub API — search, book, pay? That is a different
> document: the [Tenant integration guide](/docs/tenant-integration.md). Come
> here only to put a "Create account" form on your own page.

---

## 1. The flow

Three steps, and the third one is a redirect:

1. `POST` the form to `/api/v1/auth/signup/`.
2. Read `sign_in_url` out of the response.
3. `window.location = sign_in_url`.

That is the whole integration. The visitor lands in their workspace signed in,
without typing their password a second time.

**Base URL:** `https://merchant.meshhub.ai`

---

## 2. The request

```http
POST /api/v1/auth/signup/
Content-Type: application/json
```

```json
{
  "company_name": "Acme Travel AI",
  "email": "you@company.com",
  "password": "correct-horse-battery",
  "markup_percent": 15
}
```

| Field | Required | Notes |
|---|---|---|
| `company_name` | yes | The business signing up. Becomes the workspace name. Max 255 characters. |
| `email` | yes | Becomes the sign-in address. Must not already have a MeshHub account. |
| `password` | yes | Checked against the same strength rules as our own signup page. Send it once — not twice. |
| `markup_percent` | no | 0–100. The percentage added on top of the supplier's price; that difference is the tenant's profit. |

### About `markup_percent`

It is stored against **every** vertical, so a category opened later carries the
number the visitor chose rather than reverting to a platform default. Omitting
the field is not the same as sending `0`: omit it and the workspace starts on
MeshHub's default markup. Send `0` only if you mean no margin at all.

The workspace owner can change it per vertical later, in the dashboard — one
rate for flights, one for hotels, one for parcel delivery, one for car hire and
one covering every salon service.

---

## 3. The response

`201 Created`

```json
{
  "tenant_id": "e790fac8-14e8-480e-b8a6-1e6b530705cd",
  "email": "you@company.com",
  "company_name": "Acme Travel AI",
  "markup_percent": "15.00",
  "categories": ["shipping"],
  "sign_in_url": "https://merchant.meshhub.ai/tenant-portal/continue/?t=…",
  "sign_in_expires_at": "2026-08-21T06:30:58Z",
  "dashboard_url": "https://merchant.meshhub.ai/tenant-portal/dashboard/"
}
```

| Field | What it is for |
|---|---|
| `sign_in_url` | Where to send the browser next. See the rules below. |
| `sign_in_expires_at` | When that link stops working. Treat this as the authority, not a number written in this page. |
| `dashboard_url` | Where the visitor signs in from then on. Safe to show, link, or email. |
| `categories` | What this workspace may sell today (see §6). |
| `tenant_id` | The workspace's id. Useful in a support ticket. |

### Rules for `sign_in_url`

It is a credential, and a short-lived one. Four things to get right:

- **Navigate to it at the top level** — `window.location`, or a normal link
  click. Not `fetch`, not an `<iframe>`: a session cookie is not set on a
  background request, and the visitor will appear not to be signed in.
- **Use it immediately.** It works **once** and expires within minutes.
- **Never log, cache, email, or put it in an analytics event.** Anyone holding
  it within its lifetime can open that workspace.
- **Do not treat expiry as an error.** If it lapses, the account is fine —
  send the visitor to `dashboard_url` and they sign in with the password they
  just chose.

---

## 4. A complete form handler

No framework, nothing to install. Paste and adapt the selectors.

```html
<form id="signup">
  <input name="company_name" placeholder="Acme Travel AI" required />
  <input name="email" type="email" placeholder="you@company.com" required />
  <input name="password" type="password" placeholder="At least 8 characters" required />
  <input name="markup_percent" type="range" min="0" max="100" value="15" />
  <button type="submit">Create Account</button>
  <p data-error="__all__" hidden></p>
</form>
```

```js
const MESHHUB = "https://merchant.meshhub.ai";
const form = document.getElementById("signup");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  clearErrors(form);

  const data = Object.fromEntries(new FormData(form));
  const button = form.querySelector("button");
  button.disabled = true;

  try {
    const response = await fetch(`${MESHHUB}/api/v1/auth/signup/`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        company_name: data.company_name,
        email: data.email,
        password: data.password,
        markup_percent: Number(data.markup_percent),
      }),
    });

    if (response.status === 429) {
      const wait = Number(response.headers.get("Retry-After") || 60);
      showError(form, "__all__", `Too many attempts. Try again in ${wait}s.`);
      return;
    }

    const body = await response.json();

    if (!response.ok) {
      // Field errors arrive keyed by the field you sent.
      for (const [field, messages] of Object.entries(body.fields || {})) {
        showError(form, field, messages[0]);
      }
      if (!body.fields) showError(form, "__all__", body.message);
      return;
    }

    // Top-level navigation — not fetch, not an iframe.
    window.location = body.sign_in_url;
  } catch {
    showError(form, "__all__", "Could not reach MeshHub. Please try again.");
  } finally {
    button.disabled = false;
  }
});

function showError(form, field, message) {
  const el = form.querySelector(`[data-error="${field}"]`);
  if (el) { el.textContent = message; el.hidden = false; }
}

function clearErrors(form) {
  form.querySelectorAll("[data-error]").forEach((el) => {
    el.textContent = "";
    el.hidden = true;
  });
}
```

Add a `<p data-error="email" hidden></p>` next to each input you want to show a
message under.

---

## 5. Errors

Every failure uses the same flat envelope. **Branch on `error`, never on the
HTTP status.**

```json
{
  "error": "validation_error",
  "code": "VALIDATION_ERROR",
  "message": "Request validation failed.",
  "fields": {
    "email": ["An account with this email already exists. Sign in instead."]
  }
}
```

`fields` is keyed by the field names **you sent**, so each message can be
rendered under the input it belongs to.

| Situation | Status | `fields` key | What to show |
|---|---|---|---|
| Email already registered | 400 | `email` | The message, plus a link to `dashboard_url` to sign in |
| Password too short, too common, all numeric | 400 | `password` | The messages — there may be several |
| `markup_percent` outside 0–100 | 400 | `markup_percent` | The message, or clamp the slider so it cannot happen |
| Missing or malformed field | 400 | that field | The message |
| Too many signups from this address | 429 | — | "Try again in N seconds", from the `Retry-After` header |

Signup is rate-limited per client IP and globally. Honour `Retry-After`; do not
retry in a tight loop. Note that everyone behind one office network or NAT
shares an IP.

---

## 6. What happens next

The visitor lands on their dashboard, where they can:

- set their markup per vertical,
- create an API key — signup deliberately does not return one, because a key in
  a browser response is readable by anything on the page. Keys are shown once,
  in the dashboard, to the person who created them,
- see bookings, payouts, and support.

**A new workspace is offered parcel delivery today.** Flights, hotels, car
rental and salon services read "Coming soon" on the dashboard and take no
markup. `categories` in the response names what that workspace is offered, so
read it rather than hard-coding a list — what is on offer changes — and do not
advertise the rest on your signup page.

---

## 7. The Google button

A visitor who would rather not choose a password gets the same workspace
through Google. There is no API call for this one — it is a **redirect**, and
your button does nothing but navigate:

```
https://merchant.meshhub.ai/tenant-portal/signup/google/
  ?company_name=Acme%20Travel%20AI
  &markup_percent=15
```

```js
document.getElementById("google").addEventListener("click", () => {
  const url = new URL("https://merchant.meshhub.ai/tenant-portal/signup/google/");
  url.searchParams.set("company_name", companyInput.value.trim());
  if (markup !== null) url.searchParams.set("markup_percent", String(markup));
  window.location.assign(url.href);   // top-level navigation, not fetch
});
```

- **Same two fields as §2**, by the same names and with the same bounds, so
  the two doors cannot disagree. `markup_percent` is optional here too, and
  omitting it means MeshHub's default rather than `0`.
- **`window.location`, never `fetch` and never an `<iframe>`.** Google will
  not render its consent screen in a frame, and a background request cannot
  set the session cookie that the whole flow depends on.
- **`company_name` may be empty.** If you have not asked for it by the time
  the button is pressed, leave it off — MeshHub asks the one question on its
  own page and carries `markup_percent` through it.
- **No `sign_in_url` comes back**, because nothing is posted: the visitor is
  already on our host by the time the workspace exists. They end on
  `/tenant-portal/dashboard/`, signed in.
- **An email that already has a MeshHub account signs in instead**, and keeps
  the workspace it already had. No duplicate is created, and no second
  workspace is created for someone who presses the button twice.

**GitHub is not available.** Google is the only provider configured; leave a
GitHub button out rather than shipping one that fails.

---

## 8. Checklist before you ship

- [ ] The redirect to `sign_in_url` is a top-level navigation.
- [ ] `sign_in_url` is not logged, cached, or sent to analytics.
- [ ] Field errors render under the right input; `email` offers a way to sign in.
- [ ] `429` shows a wait, not a generic failure, and does not auto-retry.
- [ ] The submit button is disabled while the request is in flight.
- [ ] The page does not advertise categories the workspace cannot sell.
- [ ] The Google button navigates the top-level window; it does not `fetch`.
- [ ] The Google button passes the same `company_name` the form would have sent.
- [ ] There is no GitHub button.
