# @meshhub/sdk

Official TypeScript SDK for the [MeshHub](https://merchant.meshhub.ai) tenant
API — search, book, pay, and track orders across travel, shipping,
car-rental, hotel, and salon suppliers from one typed client.

Zero runtime dependencies. Node ≥ 18 (uses global `fetch`). Ships ESM + CJS
with full type declarations generated from the live OpenAPI contract.

## Install

```bash
npm install @meshhub/sdk
```

## Setup

```ts
import { MeshHubClient } from "@meshhub/sdk";

const meshhub = new MeshHubClient({
  apiKey: process.env.MESHHUB_API_KEY!, // "mh_..." — keep it server-side only
  baseUrl: "https://merchant.meshhub.ai",
});

await meshhub.health(); // { status: "ok" }
```

> **Never expose the API key in browser code.** Call MeshHub from your
> server (API route, backend) and keep the key in an environment secret.

## Search → book → pay

```ts
// 1. Search offers (async task handled for you)
const { offers, field_schema } = await meshhub.searchAndWait({
  category: "shipping",
  extra: { origin: "DE", destination: "ES", weight_kg: 2.5 }, // ISO 3166-1 alpha-2
});
const offer = offers[0];

// 1a. (Optional) Show full offer detail on demand — the "more details"
//     button. Synchronous, no polling. For hotels, pass hotel_code from the
//     offer's extras. Non-hotel offers return { detail: {} }.
const { detail } = await meshhub.offerDetails({
  supplier_id: offer.supplier_id,
  category: offer.category,
  offer_id: offer.offer_id,
  extra: { hotel_code: offer.extras?.hotel_code },
});
// detail.description, detail.amenities, detail.images, detail.address, ...

// 1b. (Optional, flights) Pre-booking extras catalogue for ONE offer.
//     Every offer's `supported_extras_kinds` says which kinds exist
//     (["seat", "luggage", ...]) at zero cost; the full priced catalogue
//     is opt-in and can take up to ~30s (airline-side seat maps):
if (offer.supported_extras_kinds?.length) {
  const { detail } = await meshhub.offerDetails({
    supplier_id: offer.supplier_id,
    category: "travel",
    offer_id: offer.offer_id,
    extra: { include_extras: true },
  });
  const catalogue = detail.extras as OfferExtrasCatalogue | undefined;
  // catalogue.items (flat, same shape as post-booking extras),
  // catalogue.seat_map, catalogue.prices_indicative === true
}

// 2. Book the offer. Pass your own customer id so MeshHub stores the
//    customer, prefills repeat bookings, and can charge saved cards later.
const order = await meshhub.bookAndWait({
  category: "shipping",
  supplier_id: offer.supplier_id,
  offer_id: offer.offer_id,
  end_user_external_id: "user-42",
  end_user: {
    first_name: "Ada",
    last_name: "Lovelace",
    email: "ada@example.com",
    phone: "+441234567",
  },
  extra: { /* per-category fields — collect what field_schema marks required */ },
  quoted_price: offer.price,
});

// 3. Take payment. Branch on requires_action:
const payment = await meshhub.payments.payOrder(order.supplier_order_id);
if (payment.requires_action) {
  // First payment for this customer: open the Revolut checkout with
  // payment.order_token (JS widget) or redirect to payment.checkout_url.
} else {
  // Saved card charged server-side — nothing to render.
}
// The definitive outcome arrives via the payment_succeeded /
// payment_failed webhook (see below).
```

`searchAndWait` / `bookAndWait` dispatch the operation and poll
`GET /tasks/{id}/` until it finishes — immediately, then backing off
1 s → 2 s → 4 s → 8 s (cap) up to a 120 s budget, so fast searches
resolve in a poll or two and long tasks don't burn your rate limit.
Configurable via `polling` (client-wide) or per call; pass `intervalMs`
for a fixed interval. Use `meshhub.search()` / `meshhub.book()` +
`meshhub.waitForTask()` when you want the task id yourself.

## Order history — no local order store needed

```ts
const page = await meshhub.orders.list({ customer: "user-42", limit: 20 });
const order = await meshhub.orders.get("VMT2");
const timeline = await meshhub.orders.timeline("VMT2");
await meshhub.orders.cancelAndWait("VMT2");
```

## Customer profiles — no local customer store needed

MeshHub is the system of record for your customers too. Key every
customer by your own user id (`end_user_external_id` at booking /
`external_id` here) and render profile pages straight from MeshHub —
keep only credentials and sessions in your own database:

```ts
// Look up a customer by YOUR id (exact match — zero or one result).
const { results } = await meshhub.endUsers.list({ external_id: "user-42" });
const profile = results[0] ?? null;

// Create or update the profile (MeshHub UUID from the lookup above).
await meshhub.endUsers.create({ external_id: "user-42", first_name: "Ada" });
await meshhub.endUsers.patch(profile.id, { phone: "+441234567" });

// Saved cards & payment history for the account page.
const cards = await meshhub.payments.listMethods("user-42");
await meshhub.payments.setDefaultMethod(cards.results[0].id);
const txns = await meshhub.payments.listTransactions({
  end_user_external_id: "user-42",
});
```

All list endpoints (orders, end users, payment methods, transactions)
return the same paginated `{count, next, previous, results}` envelope.

Also available: `meshhub.apiKeys`, `meshhub.carRentals` /
`meshhub.salons` (discovery), `meshhub.searchSchema(category)` (live
per-category search contract for dynamic forms).

## Webhooks

MeshHub pushes order, task, payment, and tracking events to your webhook
URL, signed with HMAC-SHA256. **Verify against the raw request bytes** —
a re-serialised JSON body will not match the signature:

```ts
import express from "express";
import { parseWebhook, WEBHOOK_SIGNATURE_HEADER } from "@meshhub/sdk";

const app = express();

app.post(
  "/webhooks/meshhub",
  express.raw({ type: "application/json" }), // raw body — required!
  async (req, res) => {
    try {
      const event = await parseWebhook(
        req.body, // Buffer (a Uint8Array) — exactly what was signed
        req.header(WEBHOOK_SIGNATURE_HEADER) ?? "",
        process.env.MESHHUB_WEBHOOK_SECRET!,
      );
      switch (event.event_type) {
        case "order_status_changed": // confirmed | paid | failed | cancelled
        case "task_completed":
        case "task_failed":
        case "payment_succeeded":
        case "payment_failed":
        case "payment_refunded":
        case "tracking_updated":
          break;
      }
      res.sendStatus(200);
    } catch {
      res.sendStatus(400); // signature mismatch
    }
  },
);
```

(The `/payments/webhooks/revolut/` path in the API schema is inbound from
the payment provider to MeshHub — your app never calls or receives it.)

## Error handling

Every API failure throws a typed subclass of `MeshHubError`. Branch on the
class (or `error.slug`) — **not** on the HTTP status; supplier and payment
failures arrive as HTTP 400.

| Class                   | `slug`                  | Meaning / action                                  |
| ----------------------- | ----------------------- | ------------------------------------------------- |
| `ValidationError`       | `validation_error`      | Fix the request; see `error.fields`               |
| `SupplierError`         | `supplier_error`        | Supplier rejected/failed; see `error.supplierError` |
| `SupplierTimeoutError`  | `supplier_timeout`      | Supplier slow — retryable                         |
| `PaymentError`          | `payment_error`         | Charge declined / not payable                     |
| `AuthenticationError`   | `authentication_failed` | Missing/invalid API key                           |
| `PermissionDeniedError` | `permission_denied`     | Key lacks the required scope                      |
| `NotFoundError`         | `not_found`             | Unknown id (or another tenant's)                  |
| `RateLimitError`        | `rate_limit_exceeded`   | Back off `error.retryAfterSeconds`                |
| `ServerError`           | `internal_server_error` | MeshHub-side — retryable                          |

Async task failures throw `TaskFailedError` (with the same typed envelope
at `.error`) and `TaskTimeoutError` when polling exceeds its budget.
`error.retryable` is `true` where a retry can plausibly succeed.

## Synchronous mode — one request, no polling

`new MeshHubClient({ ..., executionMode: "sync" })` makes async endpoints
block and return the finished task inline — the whole
search-or-book round trip is a single HTTP request. This is the
simplest way to integrate and a fine default for low-throughput
backends; move to the default async mode (plus webhooks) when you need
to fan out requests or respond faster than the slowest supplier.

You can also switch modes per call, e.g. keep the client async but make
one blocking search:

```ts
const result = await meshhub.searchAndWait(body, { executionMode: "sync" });
```

## Reference

- Full protocol documentation: <https://merchant.meshhub.ai/docs/tenant-integration.md>
- Machine-readable schema: <https://merchant.meshhub.ai/api/schema/?format=json>
- Everything else (examples, AI-builder prompt, llms.txt): <https://merchant.meshhub.ai/docs/>

## Developing this SDK

```bash
npm ci                    # install
npm run generate:types    # regenerate src/generated/openapi.ts from openapi.json
npm run typecheck && npm run lint && npm test && npm run build
```

Types are generated from the repo's OpenAPI document; CI fails if they
drift. Releases: bump `version` in package.json, tag `sdk-v<version>`, and
the publish workflow ships it to npm.
