Feathers services for Stripe
npm install feathers-stripe stripe
feathers-stripe is ESM only and requires Node.js 24 or later, stripe@^23 and @feathersjs/feathers@^5.
Every service takes a stripe instance and is registered like any other Feathers service:
import { Stripe } from 'stripe' import { CustomerService } from 'feathers-stripe' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY) app.use('stripe/customers', new CustomerService({ stripe })) const customer = await app .service('stripe/customers') .create({ email: '[email protected]' })
Protect these services! They act on your Stripe account with your secret key. Unless a user is explicitly allowed to, block all external access (REST, websockets) and only call them from your own code. See Security.
| Feathers | Stripe |
|---|---|
find |
list |
get |
retrieve |
create |
create |
update |
update |
patch |
update, with some special cases (see Available services) |
remove |
del, or the closest equivalent like cancel, void or expire |
search |
search (only Customer and Invoice) |
Methods that Stripe doesn't support for a resource reject with a NotImplemented error.
Use params.stripe to pass request options like stripeAccount, idempotencyKey or apiVersion:
await app .service('stripe/customers') .create( { email: '[email protected]' }, { stripe: { stripeAccount: 'acct_123', idempotencyKey: 'signup-42' } }, )
params.query is passed to Stripe's list method. Feathers style $ prefixes are removed, so $limit becomes limit and { created: { $gt: 1700000000 } } becomes { created: { gt: 1700000000 } }.
const invoices = await app.service('stripe/invoices').find({ query: { customer: 'cus_123', status: 'open', $limit: 50 }, })
find returns Stripe's list object, not the Feathers pagination format:
{ object: 'list', data: [/* ... */], has_more: true, url: '/v1/invoices' }
To get the next page, pass the id of the last item as starting_after. $limit is capped at 100, which is Stripe's maximum. To change that, pass your own paginate option to the service.
With paginate: false, find fetches all pages and returns a plain array:
const allInvoices = await app.service('stripe/invoices').find({ query: { customer: 'cus_123' }, paginate: false, })
Use params.query.expand to expand related objects. For get only expand is passed on. For find the whole query is passed on, so the paths need the data. prefix:
await app.service('stripe/customers').get('cus_123', { query: { expand: ['default_source'] }, }) await app.service('stripe/invoices').find({ query: { expand: ['data.subscription'] }, })
CustomerService and InvoiceService have a search method for Stripe's search query language:
const result = await app.service('stripe/customers').search({ query: { query: "email:'[email protected]'" }, })
search is a custom method. To call it from a client, add it to the service's methods when registering it.
Stripe errors are converted to Feathers errors. The original Stripe error is available as error.data.
| Stripe | Feathers |
|---|---|
StripeCardError |
PaymentError |
StripeInvalidRequestError |
BadRequest |
StripeAPIError |
Unavailable |
StripeConnectionError |
Unavailable |
StripeAuthenticationError |
NotAuthenticated |
StripePermissionError |
Forbidden |
StripeRateLimitError |
TooManyRequests |
| anything else | GeneralError |
If a required parent id like customer is missing, the service rejects with a BadRequest before calling Stripe.
Services for nested Stripe resources need the id of their parent object. It is passed in params.query or data as noted below.
| Service | Stripe resource | Notes |
|---|---|---|
AccountService |
accounts |
|
AccountLinkService |
accountLinks |
create only |
ApplicationFeeRefundService |
applicationFees.*Refund* |
Requires query.fee |
BalanceService |
balance |
get only. The id is used as stripeAccount for connected accounts |
BalanceTransactionService |
balanceTransactions |
find and get only |
BankAccountService |
customers.*Source* |
Deprecated, use PaymentMethodService. Requires query.customer |
BillingMeterService |
billing.meters |
remove deactivates the meter |
BillingMeterEventService |
billing.meterEvents |
create only |
ChargeService |
charges |
patch with { capture: true } captures. remove refunds the charge |
CheckoutSessionService |
checkout.sessions |
remove expires the session |
CouponService |
coupons |
|
CreditNoteService |
creditNotes |
remove voids the credit note |
CustomerService |
customers |
Supports search |
CustomerPortalConfigurationService |
billingPortal.configurations |
|
CustomerPortalSessionService |
billingPortal.sessions |
create only |
CustomerSessionService |
customerSessions |
create only |
DisputeService |
disputes |
|
EventService |
events |
find and get only |
ExternalAccountService |
accounts.*ExternalAccount* |
Requires query.account, for create data.account |
InvoiceService |
invoices |
patch with { pay: true } pays the invoice. Supports search |
InvoiceItemService |
invoiceItems |
|
PaymentIntentService |
paymentIntents |
patch with { capture: true }, { confirm: true } or { cancel: true } captures, confirms or cancels. Other fields are passed to Stripe |
PaymentLinkService |
paymentLinks |
|
PaymentMethodService |
paymentMethods |
patch with { attach: true, customer } attaches. remove detaches |
PayoutService |
payouts |
remove cancels the payout |
PlanService |
plans |
Deprecated, use PriceService |
PriceService |
prices |
|
ProductService |
products |
|
PromotionCodeService |
promotionCodes |
|
RefundService |
refunds |
|
SetupIntentService |
setupIntents |
|
SourceService |
sources |
Deprecated, use PaymentMethodService. create with data.customer attaches to a customer |
SubscriptionService |
subscriptions |
remove cancels the subscription |
SubscriptionItemService |
subscriptionItems |
|
SubscriptionScheduleService |
subscriptionSchedules |
remove cancels the schedule |
TaxRateService |
taxRates |
|
TokenService |
tokens |
get and create only |
TransferService |
transfers |
remove reverses the transfer |
TransferReversalService |
transfers.*Reversal* |
Requires query.transfer, for create data.transfer |
WebhookService |
- | See Webhooks |
Example for a nested resource:
app.use('stripe/external-accounts', new ExternalAccountService({ stripe })) const bankAccount = await app .service('stripe/external-accounts') .create({ account: 'acct_123', external_account: 'btok_123' }) // stripe.accounts.createExternalAccount('acct_123', { external_account: 'btok_123' }) await app.service('stripe/external-accounts').get('ba_123', { query: { account: 'acct_123' }, }) // stripe.accounts.retrieveExternalAccount('acct_123', 'ba_123')
setupWebhook registers a webhook service at the given route. It only allows create and verifies the stripe-signature header of every request against the raw request body before any handler runs. The verified event is passed to the service as params.stripeEvent. External calls without a verified event (e.g. via websockets) are rejected with NotAuthenticated.
Stripe signs the raw request body, so keep it around when parsing JSON:
import { json } from '@feathersjs/express' app.use( json({ verify: (req, res, buf) => { req.rawBody = buf }, }), )
Then register the webhook with handlers for the event types you need. A handler for customer.subscription.created is looked up at handlers.customer.subscription.created. The return value of the handler is sent back to Stripe. Events without a handler resolve with {}.
import { Stripe } from 'stripe' import { setupWebhook } from 'feathers-stripe' const service = setupWebhook(app, 'stripe/webhook', { endpointSecret: process.env.STRIPE_WEBHOOK_SECRET, stripe: new Stripe(process.env.STRIPE_SECRET_KEY), handlers: { customer: { subscription: { async created({ object, event, params, app }) { // object is the subscription return {} }, }, }, }, }) // `service` is the registered service, so hooks can be added service.hooks({ before: { create: [logEvent] } })
The webhook service must stay reachable from outside. Don't add the internal-only hook from Security to it.
Block external access to all Stripe services that users shouldn't call directly:
import { Forbidden } from '@feathersjs/errors' const internalOnly = async (context) => { if (context.params.provider) { throw new Forbidden('You are not allowed to access this') } } app.service('stripe/customers').hooks({ before: { all: [internalOnly] }, })
Stripe limits the number of requests per second. An around hook with a rate limiter like Bottleneck keeps you below that:
import Bottleneck from 'bottleneck' // about 100 requests per second in live mode. Use { minTime: 40 } for test mode const readLimiter = new Bottleneck({ minTime: 10 }) const writeLimiter = new Bottleneck({ minTime: 10 }) const rateLimit = async (context, next) => { const limiter = context.method === 'find' || context.method === 'get' ? readLimiter : writeLimiter await limiter.schedule(() => next()) } app.service('stripe/customers').hooks({ around: { all: [rateLimit] }, })
A Feathers app that creates payment intents from its own orders service and handles the result in a webhook. The Stripe services themselves are not reachable from outside.
import { feathers } from '@feathersjs/feathers' import express, { errorHandler, json, notFound, rest, urlencoded, } from '@feathersjs/express' import { Forbidden } from '@feathersjs/errors' import { Stripe } from 'stripe' import { PaymentIntentService, setupWebhook } from 'feathers-stripe' const stripe = new Stripe(process.env.STRIPE_SECRET_KEY) const app = express(feathers()) app.use( json({ verify: (req, res, buf) => { req.rawBody = buf }, }), ) app.use(urlencoded({ extended: true })) app.configure(rest()) app.use('stripe/payment-intents', new PaymentIntentService({ stripe })) app.service('stripe/payment-intents').hooks({ before: { all: [ async (context) => { if (context.params.provider) { throw new Forbidden('You are not allowed to access this') } }, ], }, }) // Your own service decides what a user may pay for app.use('orders', { async create(data) { const paymentIntent = await app.service('stripe/payment-intents').create({ amount: 2000, currency: 'eur', automatic_payment_methods: { enabled: true }, metadata: { product: data.product }, }) // The client confirms the payment with Stripe.js return { clientSecret: paymentIntent.client_secret } }, }) setupWebhook(app, 'stripe/webhook', { endpointSecret: process.env.STRIPE_WEBHOOK_SECRET, stripe, handlers: { payment_intent: { async succeeded({ object }) { console.log('Payment succeeded', object.id) return {} }, }, }, }) app.use(notFound()) app.use(errorHandler()) await app.listen(3030)
feathers-stripeis ESM only and requires Node.js 24 or later.stripe@^23is required. Stripe no longer accepts params and request options in the same argument, see the migration guide.feathers-stripepasses them correctly now.expandis no longer a request option. Useparams.query.expandinstead ofparams.stripe.expand. The old way still works but is deprecated.setupWebhookregisters the webhook service itself and needsreq.rawBody. Remove your ownapp.use(route, ...)for it. The signature is now checked for every external request.CardServiceandTransactionServiceare removed. UsePaymentMethodServiceandBalanceTransactionService.- Service methods always return a promise.
NotImplementedandBadRequestreject instead of being thrown synchronously. - A missing parent id (
customer,account,transfer,fee) rejects with aBadRequestinstead of calling Stripe. ApplicationFeeRefundServiceexpects the fee id inparams.query.feefor all methods, no longer inparams.stripe.fee. TheParamsWithStripeFeetype is removed.StripePermissionErroris converted toForbiddeninstead ofGeneralError.
Licensed under the MIT license.