---
title: "Importing Existing Customers (Legacy Cutover)"
description: "How to move mid-plan customers, remaining wallets, trials, and packs into QuotaStack without double-granting or resetting billing periods."
order: 8
---

# Importing Existing Customers (Legacy Cutover)

When you already have users in another system (homegrown ledger, Stripe + spreadsheet, etc.), do **not** call normal `POST /v1/subscriptions` for everyone. That path starts a new period at “now” and fires full plan activation credits.

Use the **import** API instead.

**Pattern:** LEGACY CUTOVER · MID-PLAN IMPORT

> **Mental Model:** Moving existing users in is **not** the same as signing them up. `POST /v1/subscriptions` restarts the period at "now" and fires full activation grants; import takes **your** period clocks and seeds only the credit that is actually left.

## Quick Take

- Use `POST /v1/imports/customers` for mid-plan users — never `POST /v1/subscriptions`
- Seed the **remaining** balance, not the original grant amount, via `credit_blocks[]` with `source: migration`
- Defaults are deliberate: `grant_policy: none` (no re-grant) and `emit_webhooks: false` (quiet cutover)
- Run the whole export through `dry_run: true` first — same validation, no writes

## What import is for

| You have… | Import does… |
|---|---|
| Users mid monthly/annual plan | Creates subscription with **your** period start/end |
| Remaining credits / packs | Seeds **remaining** millicredits only |
| Free looks + paid packs | Multiple credit blocks with priority/expiry |
| Trials, cancel-at-period-end | Status + `trial_ends_at` / `cancel_at_period_end` |
| Wallet-only customers | Customer + credits, no subscription |
| Gauges (seats used) / custom entitlements | `gauge_states` / `entitlement_overrides` |

## What import is not for

- Replaying full historical usage or invoices (keep that in your old system).
- Collecting or prorating money (your PSP).
- Open AI reservations (drain them before cutover).
- Auto-importing your plan catalog (create plans/metrics in QuotaStack first).

## Prerequisites

1. Catalog exists in the target environment (sandbox first): metrics, metering rules, plans, variants, entitlements.
2. Map every legacy plan id → a QuotaStack `plan_variant_id`.
3. Export **remaining** balances, not original grant amounts.
4. Finish or release in-flight reservations so held credits are settled.

## API

```http
POST /v1/imports/customers
X-API-Key: qs_live_...
Idempotency-Key: import:{your_user_id}
Content-Type: application/json
```

### Mid-plan annual example

Customer paid Pro Annual 2025-11-01 → 2026-11-01, **42 credits** left (42,000 millicredits).

```bash
curl -s -X POST https://api.quotastack.io/v1/imports/customers \
  -H "X-API-Key: $API_KEY" \
  -H "Idempotency-Key: import:user_abc" \
  -H "Content-Type: application/json" \
  -d '{
    "customer": {
      "external_id": "user_abc",
      "display_name": "Ada"
    },
    "subscription": {
      "plan_variant_id": "'"$PRO_ANNUAL_VARIANT"'",
      "status": "active",
      "started_at": "2025-11-01T00:00:00Z",
      "current_period_start": "2025-11-01T00:00:00Z",
      "current_period_end": "2026-11-01T00:00:00Z",
      "grant_policy": "none"
    },
    "credit_blocks": [
      {
        "credits": 42000,
        "source": "migration",
        "reason": "Remaining balance at cutover",
        "expires_at": "2026-11-01T00:00:00Z",
        "idempotency_key": "migrate-block:user_abc:remaining"
      }
    ],
    "options": {
      "emit_webhooks": false,
      "dry_run": false
    }
  }'
```

**Critical:** `"grant_policy": "none"` (default). Do not use `plan_activation` unless you intentionally want a full new-plan grant.

### Wallet-only (no subscription)

```json
{
  "customer": { "external_id": "user_xyz" },
  "credit_blocks": [
    {
      "credits": 10000,
      "source": "migration",
      "reason": "Prepaid wallet remaining",
      "idempotency_key": "migrate-block:user_xyz:wallet"
    }
  ]
}
```

### Free looks + pack stack (hybrid)

Model free value as credits, not a second plan, unless you need two different entitlement sets:

```json
{
  "customer": { "external_id": "user_pack" },
  "subscription": {
    "plan_variant_id": "VARIANT_FREE_OR_LABEL",
    "status": "active",
    "current_period_start": "2026-01-01T00:00:00Z",
    "current_period_end": "2027-01-01T00:00:00Z",
    "grant_policy": "none"
  },
  "credit_blocks": [
    {
      "credits": 3000,
      "source": "promotional",
      "reason": "Free looks remaining",
      "priority": 0,
      "idempotency_key": "migrate-block:user_pack:free"
    },
    {
      "credits": 18000,
      "source": "migration",
      "reason": "Weekly pack remaining",
      "priority": 10,
      "expires_at": "2026-08-01T00:00:00Z",
      "idempotency_key": "migrate-block:user_pack:weekly"
    }
  ]
}
```

Burn-down uses priority then soonest expiry — packs burn before free looks when priority is higher.

### Trial

```
"subscription": {
  "plan_variant_id": "VARIANT_PRO",
  "status": "trialing",
  "trial_ends_at": "2026-08-15T00:00:00Z",
  "current_period_start": "2026-07-15T00:00:00Z",
  "current_period_end": "2026-08-15T00:00:00Z",
  "grant_policy": "none"
}
```

### Cancel at period end already requested

```
"subscription": {
  "plan_variant_id": "VARIANT_PRO",
  "status": "cancelling",
  "cancel_at_period_end": true,
  "current_period_start": "2026-07-01T00:00:00Z",
  "current_period_end": "2026-08-01T00:00:00Z",
  "grant_policy": "none"
}
```

## Dry-run first

```
"options": { "dry_run": true }
```

Returns the same validation and projected shape **without writing**. Run your full export through dry-run before live.

## Idempotency

| Key | Purpose |
|---|---|
| Header `Idempotency-Key: import:{user_id}` | Whole request safe to retry (24h cache) |
| `credit_blocks[].idempotency_key` | Survives beyond 24h — use forever-stable keys per logical block |

Re-running the same keys must not double balance.

## Repair: missing credits only

If the customer already imported with a subscription, and you only forgot a pack:

```json
{
  "customer": { "external_id": "user_abc" },
  "credit_blocks": [
    {
      "credits": 5000,
      "source": "migration",
      "reason": "Missed pack on first pass",
      "idempotency_key": "migrate-block:user_abc:missed-pack"
    }
  ]
}
```

Omit `subscription`. QuotaStack will not rewrite period clocks. Creating a second subscription while one is live returns **409**.

## Cutover checklist

1. Build catalog in **sandbox**.
2. Export legacy snapshot (remaining balances + period ends).
3. `dry_run=true` over all rows; fix 422s.
4. Import sandbox; spot-check balance, period end, `entitlements/check`, `consume`.
5. Drain reservations in production legacy system.
6. Import **live** with the same script.
7. Verify sample of live users.
8. Flip app config to QuotaStack for metering/entitlements.
9. Keep legacy ledger read-only for one cycle (disputes).
10. At real period end: normal prepaid charge + `POST /v1/subscriptions/{id}/renew` (or postpaid auto-advance).

## Postpaid / overage

Invoice anything already over plan **before** cutover from your old system. After import, QuotaStack only records new usage and overage for the open period.

## Common mistakes

| Mistake | Result |
|---|---|
| Using `POST /v1/subscriptions` for mid-plan users | New period + full credit grant |
| Seeding original grant amount, not remaining | Over-credit |
| Omitting period end | 422 or wrong renewals |
| Cutover with open reservations | Balance/holds disagree with reality |
| Expecting import to charge the customer | QS never charges; you already did |

## Related

- [Credits](/docs/concepts/credits)
- [Subscriptions](/docs/concepts/subscriptions)
- [Pack stacking](/docs/cookbook/pack-stacking)
- [Free tier credits](/docs/cookbook/free-tier-credits)

## Concepts Used

- [Credits](/docs/concepts/credits)
- [Subscriptions](/docs/concepts/subscriptions)
- [Idempotency](/docs/concepts/idempotency)
- [Customer identification](/docs/concepts/customer-identification)
