feathers-stripe
v4.1.0
Published
A Feathers service for Stripe
Readme
feathers-stripe
Feathers services for Stripe
Installation
npm install feathers-stripe stripefeathers-stripe is ESM only and requires Node.js 24 or later, stripe@^23 and @feathersjs/feathers@^5.
Usage
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.
Method mapping
| 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.
Request options
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' } },
)Querying
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 },
})Pagination
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,
})Expanding responses
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'] },
})Search
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.
Errors
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.
Available services
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')Webhooks
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.
Security
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] },
})Rate limiting
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] },
})Complete example
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)Upgrading from v3
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.
License
Licensed under the MIT license.
