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: 'jane@example.com' })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: 'jane@example.com' },
{ 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:'jane@example.com'" },
})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.
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.
