quotastack Docs
Docs / Cookbook / Importing Existing Customers (Legacy Cutover)

Importing Existing Customers (Legacy Cutover)

How to move mid-plan customers, remaining wallets, trials, and packs into QuotaStack without double-granting or resetting billing periods.

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

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.

What import is for

You have…Import does…
Users mid monthly/annual planCreates subscription with your period start/end
Remaining credits / packsSeeds remaining millicredits only
Free looks + paid packsMultiple credit blocks with priority/expiry
Trials, cancel-at-period-endStatus + trial_ends_at / cancel_at_period_end
Wallet-only customersCustomer + credits, no subscription
Gauges (seats used) / custom entitlementsgauge_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

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).

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)

{
  "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:

{
  "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

KeyPurpose
Header Idempotency-Key: import:{user_id}Whole request safe to retry (24h cache)
credit_blocks[].idempotency_keySurvives 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:

{
  "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

MistakeResult
Using POST /v1/subscriptions for mid-plan usersNew period + full credit grant
Seeding original grant amount, not remainingOver-credit
Omitting period end422 or wrong renewals
Cutover with open reservationsBalance/holds disagree with reality
Expecting import to charge the customerQS never charges; you already did

Concepts used