Migrate from Stripe
Rehearse the move with the Stripe importer in test mode, then import your live account, because everything but payment methods can follow your customers.
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 the two, and no endpoint that accepts an imported payment method.
So every customer with an active subscription has to authorise a payment method with BillKit once. Plan for the tail of customers who never respond, and do not schedule the cutover 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. 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 works in both modes, and the Stripe key follows the console. In test mode it takes a test
key (rk_test_ or sk_test_) and copies your Stripe test account into BillKit test mode: rehearse
there first. In live mode it takes a live restricted key (rk_live_) only. A live secret key
(sk_live_) is refused, because it can move money and the importer only reads. A key for the other
mode is refused with a message saying so. See Importing your live account
for what a live import asks of you.
Give it a Stripe restricted key with read access to Customers, Subscriptions, Tax IDs, Products, Prices and Coupons. Connecting checks every one of those, and names the permission Stripe says is missing. A scan session lasts 30 minutes; if it runs out, the wizard asks you to paste the key again and keeps your selection. The key is encrypted at rest and deleted when the import finishes or the session expires. Only its last four characters are ever shown back.
What comes across
| Stripe | BillKit | Notes |
|---|---|---|
| Customer | cus_… | Email, name, metadata, billing country and VAT number. The country is the billing address's, or else the one the VAT number names. Each VAT number is checked with VIES during the import and stored with the result, so reverse charge applies to the ones VIES confirms; one VIES rejects is left off and listed in the run log. A name longer than 255 characters is shortened; a customer whose email is longer than 320 characters or malformed is skipped and counted in the dry run. The Stripe id goes in metadata.stripe_customer_id. |
| Product | prod_… | Name, metadata, and whether it is archived. Stripe id in metadata.stripe_product_id. |
| Price | price_… | Recurring, licensed, monthly or yearly, with a fixed unit_amount. Amount, currency, interval, tax_behavior and metadata are copied. An archived Stripe price arrives archived: it keeps billing the subscriptions on it and sells nothing new. The product's default price is the one Stripe names as its default_price. The currency has to be enabled on your account. Stripe id in metadata.stripe_price_id. |
| Subscription | sub_… | Single-item only. Status, the current period, and the trial start and end, copied as they are. Cancelled Stripe subscriptions are not imported; their history stays in Stripe. The Stripe id is kept, so a re-run never creates the same subscription twice. |
| Discount | coup_… | One discount per subscription, including a discount on the Stripe customer (which Stripe applies to every subscription with none of its own), percent or amount off in the price's currency, carries over and is applied to its BillKit renewals until Stripe's own end: forever never ends, repeating ends at the discount's end, and a once discount still on the subscription (Stripe removes one when it is used) is applied to the first BillKit charge. A coupon limited to certain products discounts only subscriptions on those products, and stays limited to their imported prices in BillKit. It lands as an inactive coupon with the code STRIPE-…, so nobody can redeem it at checkout. |
Most statuses keep their name. The exceptions: unpaid becomes past_due, incomplete_expired
becomes canceled, and Stripe’s paused (a trial that ended with no payment method) becomes
incomplete, which does not entitle the customer. Stripe’s own incomplete (a first payment that
never completed, which Stripe expires within a day) is not imported. A subscription whose collection you paused in
Stripe keeps its status and arrives with renewal_state: "paused".
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 the customers, products and prices
it already created. Your own keys in Stripe metadata, a user_id for instance, come across beside
them.
What does not come across
| Not imported | What to do |
|---|---|
| Payment methods and mandates | Not portable between processors. See Re-authorisation. |
| Invoices, payments, refunds, disputes, credit notes | Keep Stripe readable for your history. |
| Coupons you sell with, and promotion codes | Recreate them in BillKit before you cut over. Only a discount already on an imported subscription comes across. |
| Stacked discounts | A subscription with more than one discount (or an amount off in another currency, or a one-time customer discount that Stripe would spend on whichever of several subscriptions renews first) is skipped and counted in the dry run, because imported at list price its customer would pay more. |
| Tax exemption | BillKit has no tax-exempt flag: reverse charge follows from a VAT number VIES confirms, and nothing else. The dry run counts customers Stripe treats as exempt, or as reverse-charged without a VAT number, because BillKit will charge them VAT. |
| Addresses beyond the country, phone, description | Not copied. Only country_code matters to BillKit; a customer with no country in Stripe is counted in the dry run and charged no VAT until you set one with POST /v1/customers/{id}. |
| Subscriptions in a currency you have not enabled | Skipped and counted in the dry run. Enable the currency first if you want them. |
| Metered, tiered, multi-item and one-time prices; daily and weekly intervals; intervals of more than one month or year; a quantity other than 1 | Skipped and counted in the dry run. BillKit prices bill every month or every year, and a subscription has exactly one price and no quantity, so rebuild these by hand in the shape BillKit supports. A multi-seat subscription is skipped rather than imported as one seat, which would renew at a fraction of what the customer pays today. |
| Scheduled cancellations | A subscription set to cancel at the end of its current period (cancel_at_period_end, or a cancel_at no later than that) imports with renewal_state: canceling and cancel_at_period_end: true. It is never charged again, needs no activation and gets no email. One set to cancel on a later date is skipped and counted, because BillKit can only end a subscription at the end of a period: bringing the date forward would cut the customer off early. |
| Pauses that resume on a date | Skipped and counted. BillKit's pause has no resume date, so imported it would stay paused for good. |
Run it
Four steps in the wizard.
Connect. Paste the restricted key. The wizard checks it and opens a scan session.
Select. Page through your Stripe customers and choose which to import, or take all of them.
Dry run. This writes nothing and returns counts plus caveats. It reads your whole Stripe account, so on a large one it takes a few minutes; the wizard waits for it.
| Caveat | Meaning |
|---|---|
| unmigrateable_mandates | How many customers will need to authorise a payment method. This is the size of your re-authorisation campaign. |
| unsupported_price_shapes | Subscriptions that will be skipped. Rebuild these by hand or they will not exist in BillKit. |
| unsupported_currencies | Subscriptions skipped because they are billed in a currency your account has not enabled. |
| unsupported_schedules | Subscriptions skipped because they cancel after their current period, or resume from a pause on a date. |
| unsupported_discounts | Subscriptions skipped because their discount cannot be carried over: more than one, an amount off in another currency, a coupon since deleted at Stripe (its product restriction can no longer be read), or a one-time customer discount shared by several subscriptions. |
| incomplete_subscriptions | Stripe subscriptions whose first payment never completed. Not imported. |
| unsupported_customer_fields | Customers skipped because their email is longer than BillKit stores (320 characters) or malformed, with their subscriptions. |
| missing_country | Customers with no billing country in Stripe. BillKit charges them no VAT until you set one. |
| tax_exempt_customers, reverse_charge_without_vat_id | Customers Stripe did not charge VAT that BillKit will. |
| vat_numbers_checked | Customers with a VAT number, each checked against VIES during the import. |
| carried_discounts | Imported subscriptions that keep their Stripe discount on BillKit renewals until it ends. |
| existing_customer_collisions | Customers that already exist, matched by a stashed Stripe id or by email. A Stripe-id match is not created again, and only receives the subscriptions it is missing. A customer matched only by email is imported as a second, separate customer, because BillKit never merges customers by email, so deselect those first. |
Run. The wizard shows progress as it goes, and you can leave and come back. In live mode it
first asks you to confirm it is you, and to confirm you will stop billing these customers in Stripe
(see below). One import runs per account at a time, and a second one started
while the first is running is refused with a 409 rather than queued.
The importer is idempotent per Stripe object. A re-run after a partial failure, or after a customer added a plan at Stripe, creates only what is missing. An import that stops part-way keeps what it imported, and has already emailed those customers: activation emails go out in batches as the run goes, not only at the end.
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
Every imported subscription that is still live in Stripe (active, trialing, past_due,
unpaid, paused or incomplete there) arrives pending activation, whatever its Stripe collection
method, unless it is scheduled to cancel. As the import goes, BillKit emails each customer who holds one. The subject is “Action needed: confirm your payment method for” plus your business name. It links
to a one-time activation page, valid for 14 days. Customers with no email address get nothing.
The customer adds a payment method on that page and confirms it with a verification payment that creates the mandate. The page shows the amount before they start: it is free when your Mollie account offers only cards, and otherwise the smallest amount every method you offer accepts (at most EUR 1.00). SEPA Direct Debit cannot be set up directly, because a direct debit needs a mandate that already exists. A customer who paid by SEPA Direct Debit in Stripe can confirm with iDEAL or EPS instead, which creates a SEPA mandate, so their renewals are still collected by direct debit.
The verification charge is not refunded. Say so in whatever you send alongside the automated email.
On success BillKit sets up renewals for every pending subscription that customer holds, from
the end of the current period, and emits subscription.payment_method_updated for each. Someone
with three plans confirms once.
Until then nothing charges the subscription. It is treated like a renewal that cannot be charged
yet: serves_customer stays true until two weeks after the period they paid Stripe for, the window
a failed renewal gets. When that period had already ended at import time (a paused Stripe
subscription, say), the two weeks run from the import instead. A week before the window closes, a
customer whose link has expired gets one reminder with a fresh link. A subscription still not
activated when the two weeks are up is canceled, with cancellation_reason: "import_not_activated". Track who has not activated with
subscription.payment_method_updated, and chase them yourself too.
This includes subscriptions that Stripe billed by sending invoices. BillKit has no invoice-and-wait collection: every renewal is charged against a mandate, so those customers get the same email and have to authorise a payment method like everyone else.
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.
Verify a sender domain first as well. This is a bulk send to a list that has never had mail from BillKit, which is the hardest deliverability case there is, and an address in your own domain with SPF and DKIM behind it is the difference between the inbox and the spam folder for a message you cannot afford to have missed.
Importing your live account
Switch the console to live mode and run the same wizard with a live restricted key. A live import writes real customers and emails every one of them, so it asks for three things a rehearsal does not:
- Live payments that work. Connect Mollie in live mode, and choose its profile, first. Every activation email leads to a Mollie payment, so the import is refused until one can succeed.
- Confirmation that it is you. Connecting the key and starting the import both ask you to confirm your password, authenticator or passkey, as issuing an API key does.
- Your word that Stripe stops billing these customers. The importer only reads Stripe, so Stripe keeps charging them. Once a customer adds a payment method, BillKit charges them from the end of the period they paid Stripe for. Unless you stop Stripe, both charge for the same period.
Stop Stripe one customer at a time, when that customer moves. subscription.payment_method_updated
on an imported subscription is the signal; metadata.stripe_customer_id on the customer and
metadata.stripe_price_id on the price lead you back to the Stripe subscription to cancel. Do not
cancel all of Stripe at the start, or the customers who have not moved yet stop paying you.
You can also bring customers across one by one through Checkout instead, which collects the
payment method in the same step: create the live catalogue, send each customer a Checkout url
for their price with trial_days_override set to the days left on their Stripe period (capped at
twice the price’s own trial or 28 days, whichever is larger), and cancel their Stripe subscription
when checkout.session.completed arrives.
Sequencing a real cutover
| Step | What to do |
|---|---|
| 1 | Rehearse in test mode. Import your Stripe test account, check the dry-run numbers against your own count, and walk a few customers through the activation email. In test mode that email only goes to addresses that belong to your team (see Test mode), so give those customers your own, or turn on Email test-mode customers in the dashboard. |
| 2 | Connect Mollie in live mode and choose its profile. Check the seeded EU tax rates while you are there, and retire any country you are not registered in. |
| 3 | Fill the gaps the dry run reports: rebuild the shapes the importer skips, set a country on customers Stripe had none for, and recreate the coupons you sell with. |
| 4 | Wire your webhook handlers and verify them with billkit listen, including subscription.payment_method_updated, which tells you a customer has moved. |
| 5 | Brand the portal and emails, verify a sender domain, and tell customers about the change before the activation email reaches them. |
| 6 | Run the live import. |
| 7 | Cancel each customer’s Stripe subscription when they activate, one by one. Do not disable Stripe billing wholesale at the start, or customers who have not moved yet stop paying you. |
| 8 | Keep Stripe readable. Invoice history does 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 as an option. Cancel stops renewal at period end, unless the price has refund_on_cancel set to full or prorated, which refunds and ends access at once. Otherwise a full refund is what ends a subscription now. |
| Quantity / seats | Not supported. There is no quantity on a subscription, and the importer skips a Stripe subscription whose quantity is not 1. |
| 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. |
| SEPA Direct Debit as the first payment | Not possible. The first payment is by card, Apple Pay, iDEAL, EPS or PayPal; iDEAL and EPS create a SEPA mandate, and renewals then go by direct debit. |
| PATCH to update | POST /v1/customers/{id}, same as Stripe. A price is narrower: POST /v1/prices/{id} takes only the forward-looking fields (active, payment_methods, metadata, the refund settings, and tax_behavior once), because the amount and interval are fixed at creation. |
Id prefixes are the same for customers, products, prices and subscriptions, which is convenient
and also a trap: sub_... in your logs could be either system during the overlap. Namespace it.
BillKit