Migrate from Stripe
Import customers, plans and in-flight subscriptions with the console wizard, then get every customer to re-authorise a payment method.
The part that decides your plan
Payment credentials do not move. A Stripe card or SEPA mandate is a credential held by Stripe’s processor, and it cannot be handed to Mollie. There is no network-token migration path between these two, and no endpoint that accepts an imported payment method.
So the shape of a Stripe migration is: import the records, then ask every customer with an active subscription to authorise a payment method once. BillKit automates the asking. Plan for the tail of customers who never respond.
Everything else follows from that. Do not plan a silent cutover, and do not schedule it for a week when nobody is watching the reply-to address.
What the importer is
A wizard in the BillKit console, under Developers → Import from Stripe. It is operator-only:
the endpoints behind it authenticate with a console session, not with a bk_live_ or bk_test_
key, so there is no public API to script this against.
It is also test mode only, enforced twice. A sk_live_ or rk_live_ Stripe key is refused,
and so is a test-prefixed key whose account reports live mode. That is deliberate: the import is
the step you rehearse, and rehearsing against production is how a migration goes wrong.
You will need a Stripe restricted key with read access. It is encrypted at rest, held for 30 minutes, used only for the wizard session, and deleted when the session ends. Only its last four characters are ever echoed back.
What comes across
| Stripe | BillKit | Notes |
|---|---|---|
| Customer | cus_… | Email and name only. The Stripe id is stashed in metadata.stripe_customer_id. |
| Product | prod_… | Stripe id in metadata.stripe_product_id. |
| Price | price_… | Recurring, licensed, monthly or yearly, with a fixed unit_amount. Stripe id in metadata.stripe_price_id. |
| Subscription | sub_… | Single-item only. The current period start and end are carried over verbatim. |
Statuses map directly, with two collapses: unpaid becomes past_due and incomplete_expired
becomes canceled.
Those metadata keys are your join table. Keep them; they are how you reconcile the two systems during the overlap and how a re-run of the importer recognises what it already did.
What does not come across
| Not imported | Why |
|---|---|
| Payment methods and mandates | Not portable between processors. This is the re-authorisation step below. |
| Invoices, payments, refunds, disputes, credit notes | Not imported at all. Keep Stripe readable for your history. |
| Coupons and discounts | Recreate them in BillKit before you cut over. |
| Tax rates and customer VAT numbers | Your BillKit account already has the standard rate of every EU member state, so there is normally nothing to seed. VAT numbers have to be re-collected, because BillKit only trusts one VIES has confirmed. See Charge and record EU VAT. |
| Addresses, phone, description | Only country_code matters to BillKit, and it is not imported. Backfill it, because it drives VAT and the payment-method picker. |
| Metered, tiered, multi-item and one-time prices | Unsupported shapes are skipped and counted. Rebuild them by hand. |
| Daily and weekly intervals | BillKit prices are monthly or yearly only. |
Run it
Four steps in the wizard.
Connect. Paste the restricted key. The wizard verifies it and opens a scan session.
Select. Page through your Stripe customers and choose which to import, or take all of them. A first migration is usually all of them; a phased one is usually by cohort.
Dry run. This writes nothing and returns counts plus caveats. Three caveats to read carefully:
| Caveat | Meaning |
|---|---|
| unmigrateable_mandates | How many subscriptions will land without a payment method. This is your re-authorisation campaign size. |
| unsupported_price_shapes | Subscriptions that will be skipped. Rebuild these manually or they simply will not exist in BillKit. |
| existing_customer_collisions | Already present, by stashed Stripe id or by matching email. These are skipped rather than duplicated. |
Run. Progress streams live. One import runs per account at a time, so a second attempt while one is in flight is refused rather than queued.
The importer is idempotent per customer. A re-run after a partial failure resumes rather than duplicating.
Do the dry run twice. Once to see the numbers, and once after you have fixed whatever the caveats told you. The unsupported-shapes count is the one that quietly loses subscriptions.
Re-authorisation
This is the part your customers see, and BillKit drives it.
When the import finishes, every customer with an imported subscription that would auto-charge gets an email whose subject is “Action needed: confirm your payment method for” plus your business name. It links to a one-time activation URL, valid for 14 days.
The customer clicks through to the portal, adds a payment method, and pays a small verification charge to create the mandate. On success BillKit provisions the recurring schedule at Mollie for every pending subscription that customer holds, so someone with three plans re-authorises once.
The verification charge is not refunded. Say so in whatever you send alongside the automated email.
Until a customer completes this, their subscription exists in BillKit with the right price and the right period, and nothing charges it. Renewals silently do not happen. Track the ones that have not converted and chase them; the 14-day link expiry is the deadline you are working to.
Brand the email before you start, with POST /v1/tenant/portal_branding. An unbranded “confirm
your payment method” message asking for card details is indistinguishable from phishing, and your
customers are right to treat it that way.
Sequencing a real cutover
| Step | What to do |
|---|---|
| 1 | Rehearse in test mode. Run the importer against a Stripe test account, or your live account with a read-only restricted key, and check the dry-run numbers against your own count. |
| 2 | Rebuild what the importer skips: coupons and unsupported price shapes. Check the seeded EU tax rates while you are there, and retire any country you are not registered in. |
| 3 | Backfill country_code on every customer. Without it there is no VAT and a narrower payment-method picker. |
| 4 | Wire your webhook handlers and verify them with billkit listen. Provision on checkout.session.completed, not on a redirect. |
| 5 | Announce the change to customers before the automated email arrives, so it is expected. |
| 6 | Import. Start the re-authorisation campaign. |
| 7 | Turn off auto-charging in Stripe once a customer re-authorises in BillKit, one by one. Do not disable it wholesale at the start, or unconverted customers stop paying you. |
| 8 | Keep Stripe readable. Invoice history did not move, and tax retention rules will want it. |
API differences worth knowing early
| Stripe | BillKit |
|---|---|
| POST /v1/subscriptions | Does not exist. A subscription comes from a paid checkout. See the quickstart. |
| Publishable key | Does not exist. Client-side work uses a per-session client_secret. See Embed Checkout. |
| Cancel immediately | Not available. Cancel is always at period end. A full refund is what ends a subscription now. |
| Quantity / seats | Not supported. There is no quantity on a subscription. |
| Refunds are synchronous | They are not. Wait for refund.succeeded, and handle refund.failed. |
| Deduplication by email | BillKit never does it. Two calls with the same address give two customers. |
| Dispute lost | Not modelled. Mollie gives no explicit loss signal, so a dispute is open or won. Treat any non-won dispute as unresolved. |
| PATCH to update | POST /v1/customers/{id}, same as Stripe. A price is different: POST /v1/prices/{id} takes active and nothing else, because the amount is fixed at creation. |
Id prefixes are the same for customers, prices and subscriptions, which is convenient and also a
trap: sub_... in your logs could be either system during the overlap. Namespace it.
BillKit