npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vibedout/payments

v0.3.3

Published

A processor-independent payments interface for Cloudflare Workers: hosted checkout, refunds, payment lookup and signed-webhook parsing into normalized events, with Stripe and Mercado Pago adapters over plain fetch, a Facturapi (CFDI) client (invoices, cre

Readme

@vibedout/payments

A processor-independent payments interface for Cloudflare Workers: one PaymentsProvider every app calls instead of integrating its own processor client. Hosted checkout, confirm and fetch a payment, refund, list saved payment methods, and turn a signed webhook into normalized events, with capability flags and a required idempotency key on every write. Adapters for Stripe and Mercado Pago over plain fetch (no processor SDK), a Facturapi client for CFDI invoices, the subscription-invoice orchestration that rides on Stripe's payment-succeeded webhook, and an in-memory fake for tests.

  • Workers-first, no runtime dependencies: fetch and WebCrypto only. The three shared kits this package is built on (@vibedout/web-crypto, @vibedout/money, @vibedout/provider-kit) are bundled by the build.
  • ESM only, with types. Every module is its own subpath, so an app imports the one processor it uses.

| Import | What | |---|---| | @vibedout/payments | the contract: PaymentsProvider, the types, PaymentsError, handlePaymentWebhook, webhookInputFromRequest | | @vibedout/payments/stripe | createStripeProvider, createStripeProviderFromEnv, verifyStripeWebhookSignature, normalizeStripeEvent | | @vibedout/payments/mercadopago | createMercadoPagoProvider, createMercadoPagoProviderFromEnv, verifyMercadoPagoSignature, eventsFromMercadoPagoPayment | | @vibedout/payments/testing | FakePaymentsProvider, the recordedFetch double the adapters' own tests use, and signStripeWebhook / signMercadoPagoWebhook | | @vibedout/payments/webhook-receipts | handlePaymentWebhook and its outcomes (also on the root barrel) | | @vibedout/payments/stripe/webhook | verifyStripeWebhookSignature on its own | | @vibedout/payments/facturapi/client, /facturapi/fixture-client | the Facturapi client (invoices, credit notes, files, cancellation) and its two doubles: the recorded response and a small Facturapi in memory | | @vibedout/payments/cfdi/subscription-cfdi, /cfdi/issuance-store | the subscription-CFDI orchestration and its claim-based store | | @vibedout/payments/cfdi/rfc, /cfdi/catalogs, /cfdi/sale-invoice | the RFC check, the SAT catalogs in two languages, and the pure builders of a sale's invoice, credit note and global invoice | | @vibedout/payments/provider, /types, /errors | the pieces of the root barrel, for a narrower import |

Adapters and the Facturapi pieces are deliberately not on the root barrel: a caller that only wants the contract pulls in no processor's code, and one that wants an adapter says which on the import line.

The contract

import type { PaymentsProvider } from "@vibedout/payments";

async function startCheckout(payments: PaymentsProvider, bookingId: string, signal?: AbortSignal) {
  const session = await payments.createCheckout(
    {
      kind: "one_time",
      amount: { amountMinor: 15000, currency: "MXN" }, // integer centavos, always
      successUrl: "https://app.example/pay/success",
      cancelUrl: "https://app.example/pay/cancel",
      referenceId: bookingId, // comes back on webhook events
      idempotencyKey: `${bookingId}:checkout`, // yours, deterministic, never random
    },
    { signal }, // every call takes an optional abort signal
  );
  return session.url;
}

The rules every adapter follows

  • Money is integer minor units ({ amountMinor: 15000, currency: "MXN" }), the Money type of @vibedout/money, re-exported here. An adapter converts at its own HTTP boundary (Mercado Pago wants decimal pesos), in the currency's own minor unit, and refuses an amount it cannot read exactly instead of turning it into 0.
  • Idempotency keys are required on every write (createCheckout, refund, cancelCheckout) and are never generated here. Derive them from your own record ids (${paymentId}:checkout, ${paymentId}:refund:${refundId}) and send the same key when you retry the same operation: a lost response then cannot create a second checkout or a second refund. Printable ASCII, at most 255 characters (Stripe's limit; Mercado Pago's own limit is unverified, so keep keys under 64 characters to be safe). A key made from text a person or another system wrote (an order reference such as Pedido ñandú) is refused with invalid_request: give your own payment row an id of your own (a UUID) and derive the key from that, never from the reference.
  • Shipping, tax and discounts are adjustments; the page can expire. createCheckout({ items, adjustments: [{ kind: "shipping", name: "Envío", amount }, { kind: "tax", … }, { kind: "discount", … }], expiresAt }) (0.2.0). The payer is charged the items plus shipping and tax minus the discounts, to the centavo, whatever the provider: neither Stripe Checkout nor Mercado Pago takes a negative line, so shipping and tax are one more line each and a discount is taken off the item lines in proportion to what each costs (a discounted line is shown once, as its discounted total, a line taken to zero is left out). A discount that knows which items it came off says so, { kind: "discount", name, amount, lines: [{ index, amountMinor }] } (0.3.3, index is the position in items): it comes off those items and no others, so the page agrees line by line with the order and the invoice (a 10% pack discount that belongs to the keychains does not touch the card beside them); a discount that names none shares what the named ones left, in proportion. The lines are refused when an index is not an item, repeats, does not add up to the discount's amount, or takes more off an item than it costs (with the other discounts); lines on a shipping or tax adjustment is refused. @vibedout/commerce's paymentRequestFrom fills them in from the order's totals. Every amount is positive and in the items' currency, the discounts may not exceed the items, and a checkout that would charge nothing is refused (a free order calls no provider). If you also pass amount it must be the final total. expiresAt is milliseconds since the epoch: Stripe's expires_at (30 minutes to 24 hours after creation, so a time outside that is moved to the nearest edge, 31 minutes at the near one) and Mercado Pago's expiration_date_from/to; the answer's expiresAt is what the provider was given. A time in the past is invalid_request.
  • A checkout can be stopped, when the adapter can (0.3.0). cancelCheckout?({ checkoutId, paymentReference?, idempotencyKey }) is for an order that will not be paid (staff cancelled it, or its time ran out): the page stops taking money and a voucher or transfer already issued stops being payable. It answers { outcome: "canceled" }, { outcome: "already_ended" } (it could not take money already) or { outcome: "paid" } (the money is in, nothing was stopped: refund it), and throws PaymentsError (invalid_request for a payment that cannot be cancelled now) when the provider refused. It is optional on the interface, so an adapter written for 0.2.0 is still a PaymentsProvider: call it as payments.cancelCheckout?.(...). The provider's own webhook then says what became of the payment (payment.expired or payment.canceled), as for any other end. Pass paymentReference (the Stripe PaymentIntent or the Mercado Pago payment id a webhook already named) when you have it.
  • Branch on payments.capabilities, never on payments.id: supportsOxxo, supportsSpei, supportsInstallments, supportsPartialRefund, supportsSubscriptions, supportsSavedPaymentMethods (listPaymentMethods works on this instance: Stripe once it has customerIdFor, Mercado Pago never) and refundableMethods (which method kinds refund will submit; Stripe leaves out oxxo).
  • Provider-only knobs go in the typed extras (extras: { stripe: {…} } or { mercadopago: {…} }, the ProviderExtras type). The key for the provider not in use is ignored.
  • Retries are off, and a constructor option turns them on: retry: { attempts: 3 } on either adapter (the provider kit's RetryOptions, re-exported as a type) repeats a call that failed in a way another try may fix (a timeout, a 5xx, a 429, waiting what the answer says). It repeats only what is safe to repeat: every read, and every write, since each carries your idempotency key. A decline, a bad request or a bad key is never repeated. attempts counts the first try. A call the payer waits on (a checkout) wants two.
  • Every call can be cancelled: options.signal is chained to the call's own timeout. A call that was aborted reports unavailable and is never retryable (giving up is the caller's decision), and one whose signal was already aborted sends nothing.
  • Webhooks are the source of truth. parseWebhook(input) verifies the signature and returns normalized events; redirect pages only show UI.

Errors

Everything this package throws is a PaymentsError, built on the shared provider error family, with code, retryable, status (the processor's HTTP status), providerCode (its own error code), retryAfterMs (what Retry-After asked for), issues and cause.

| Code | Meaning | retryable | |---|---|---| | timeout | the processor did not answer in time | yes | | unavailable | could not reach it, it answered 5xx, or the caller aborted | yes (never for an abort) | | rate_limited | 429; honour retryAfterMs | yes | | auth_failed | 401 or 403: the project's key is wrong, revoked or lacks a scope | no | | configuration_invalid | built without a key or webhook secret, or with an unusable option | no | | invalid_request | the processor rejected the request, or this package refused the input before sending | no | | invalid_response | it answered, but not with a shape this package understands | no | | not_found | 404 | no | | unsupported_operation | this adapter cannot do that at all (saved methods on Mercado Pago, subscriptions on Mercado Pago) | no | | webhook_signature_invalid | a delivery failed verification: answer 4xx, change nothing | no | | webhook_expired | the signature is genuine but the timestamp is outside the replay window | no | | validation_failed | the shared code for "our validators rejected the input" (carries issues) | no | | card_declined | the processor declined the card or method | no | | already_processed | the payment or refund was already done (a charge already refunded, an HTTP 409) | no | | refund_not_supported | the payment's method cannot be refunded (OXXO on Stripe): record a manual credit | no |

retryable means "repeating the same call unchanged may succeed". It is never "repeat a purchase that might already have happened": a write that carries no idempotency key and fails in a way that leaves the outcome unknown (a timeout, a 5xx, a dropped connection) is reported retryable: false with a note to check the processor first. Every write here carries a key (reads need none), so a timeout there stays retryable: the processor dedupes the repeat. The Facturapi invoice carries its key as the body field idempotency_key when the caller gives one (idempotencyKey); without one it is the unkeyed kind.

The status mapping is one for every adapter: 401 and 403 auth_failed; 404 not_found; 408 timeout; 429 rate_limited; 423, 424 and 5xx unavailable; a 3xx invalid_response (redirects are not followed, so a credential header cannot be carried to another host); any other 4xx invalid_request. An adapter refines it with its own error body (a Stripe card_error is card_declined) and gives 409 its payment meaning, already_processed.

No secret is ever in an error. Every processor key and webhook secret (and its URL-encoded form), and any Bearer ... or Basic ... credential, is removed from the message, the stack, the cause chain, providerCode and issues of every error, even when the processor or a proxy echoes the key back.

Timeouts. Every call, the Facturapi one included, is cut at 15 seconds unless timeoutMs says otherwise, body included.

Webhooks

parseWebhook(input) takes a delivery: the headers and the RAW body (a signature covers the exact bytes the processor sent), plus the notification URL for Mercado Pago, which signs a query parameter. Build it from a Fetch Request:

import { D1ReceiptStore, handlePaymentWebhook, PaymentsError, webhookInputFromRequest } from "@vibedout/payments"; // receipts table: receiptsMigrationSql()

app.post("/webhooks/payments/:provider", async (c) => {
  const input = await webhookInputFromRequest(c.req.raw); // reads the body once, as bytes
  try {
    const outcome = await handlePaymentWebhook({
      provider: payments,
      input,
      store: new D1ReceiptStore(c.env.DB),
      handle: async (event) => applyPaymentEvent(c.env.DB, event), // your write; at most once per event.id
    });
    return c.json({ status: outcome.status }); // processed, duplicate and ignored are all 2xx
  } catch (error) {
    if (error instanceof PaymentsError) {
      if (error.code === "webhook_signature_invalid" || error.code === "webhook_expired") return c.text("refused", 401);
      // Mercado Pago's dashboard "simulate notification" sends an id that does not exist: handled, nothing to retry.
      if (error.code === "not_found") return c.json({ status: "ignored" });
      if (error.retryable) return c.text("try again", 503); // the processor redelivers
    }
    throw error; // your handler failed: its claim was released, so the redelivery is processed
  }
});

Answer a signature that does not verify (or a delivery too old to trust) with a 401, an outage of the processor with a 5xx (it delivers again), and everything that was handled, duplicate or ignored with a 2xx (it stops). A configuration_invalid or auth_failed while reading a delivery (a missing or wrong secret of your own Worker) is better a 5xx than a 2xx: a 2xx would lose the delivery for good, a 5xx keeps it coming until the secret is fixed.

  • Where the route lives is the app's choice. A project can mount it at /api/webhooks/payments/:provider, because a Worker that serves a single-page app runs only for /api/*; the /webhooks/payments/... paths below are the same route without that prefix.
  • handlePaymentWebhook does the whole inbound path in the order that is safe: verify (a bad or stale delivery throws before anything is stored, so an unsigned request can never poison the receipts), claim each event's id, run your handler, and release the claim if the handler throws so the processor's retry is processed. A delivery can carry several events (a Mercado Pago payment that was approved and then refunded): each is claimed and handled once, in order. The store is @vibedout/web-crypto's ReceiptStore, re-exported here so the package is enough on its own: D1ReceiptStore in production (copy receiptsMigrationSql() into your migrations, and run purgeClaimedBefore(ms) from a cron trigger on a window longer than the processors' retries), createMemoryReceiptStore in tests.
  • Dedupe is on event.id: the same fact delivered twice has the same id (Stripe: the evt_ id; Mercado Pago: derived from the payment's state, e.g. payment:123:approved).
  • What it cannot do: if the process dies between a claim and the end of its handler, the claim stays and the retry is answered duplicate although the effects never happened. Where that matters, record the receipt and apply the effects in one D1 batch.
  • Without it, parseWebhook(input) alone verifies and normalizes; it does not dedupe. It returns [] for a verified delivery that carries nothing this package normalizes (answer 200 so the processor stops retrying).
  • A signature is checked before the clock, so a forgery is never reported as merely late; a genuine delivery outside the replay window is webhook_expired.

Normalized events

Payment state: payment.requires_action (an OXXO voucher or SPEI transfer is waiting on the payer), payment.processing, payment.succeeded, payment.failed, payment.canceled, payment.expired (an unpaid voucher or session ran out), payment.refunded (partial or full: read payment.status and the CUMULATIVE payment.amountRefunded).

One refund's state: refund.pending, refund.succeeded, refund.failed, each carrying event.refund (providerRef: the re_… id refund() returned, paymentRef, THIS refund's amount, status, the provider's own providerStatus and, for a failure, failureReason).

Disputes: dispute.opened, and dispute.closed with event.outcome: won, lost, or inquiry_closed (an inquiry that never became a formal chargeback; no money moved for it).

A verified event the package cannot read throws invalid_response (not retryable: a retry reads it the same way): for instance a charge.dispute.closed whose status is not one of the closing ones. Answering a 5xx makes Stripe redeliver for days to no effect, so catch it in the route, store the raw body (a dead-letter row with the event id), answer 200, and alert someone: it is a money event nobody has settled. Only retryable errors (a timeout, unavailable, rate_limited) should be answered with a 5xx.

amountRefunded is cumulative on purpose: store max(stored, amountRefunded) and move the status forward only, never add it up. A replayed event is then harmless, and an older event that arrives late (neither provider guarantees delivery order) cannot roll a bigger refund back.

Refunds and disputes are not forward-only the way a payment is:

  • A refund.* event settles ONE refund row (the re_… id refund() gave you), while payment.refunded is the payment's cumulative total; a refund usually produces both. Stripe may repeat a status (a refund.updated that only adds the bank's reference), and says a settled refund can return to requires_action when the customer's bank sends the money back. So keep, per refund.providerRef, the state of the event with the newest occurredAt; do not keep the highest status. occurredAt has one-second resolution, so two events can tie: on a tie keep a final status (succeeded, failed) over pending, and failed over succeeded (the money did not arrive). This package has no getRefund: when the order still matters, look the refund up in Stripe.
  • refund.failed means the money did not reach the customer: Stripe returns it to your balance and tells you to arrange another way of paying the customer back. It does not say whether the charge's amount_refunded falls back, so re-read the payment with getPayment(refund.paymentRef) and credit the customer yourself.
  • A payment's status can stay disputed after the dispute closes: Stripe documents charge.disputed only as "whether the charge has been disputed", and nothing says it resets (to be confirmed in the sandbox run). Settle your own dispute row from dispute.closed.outcome, and do not wait for the payment to change. A lost dispute already cost you the funds when it opened (charge.dispute.funds_withdrawn), a won one gets them back (charge.dispute.funds_reinstated); neither of those two events is normalized.

testing

import { FakePaymentsProvider } from "@vibedout/payments/testing";

const payments = new FakePaymentsProvider();
// ...exercise your own code against the PaymentsProvider interface...

// Drive the webhook path with no provider at all.
const session = await payments.createCheckout({ /* … */ idempotencyKey: "order-1:checkout" });
const event = payments.simulate(session.id, "succeeded"); // or "expired", "failed", …
const input = await payments.createWebhookInput([event]); // a signed delivery, ready for parseWebhook
const events = await payments.parseWebhook(input); // the same event twice has the same id
const request = await payments.createWebhookRequest([event]); // the same delivery as a Request, for a route test

// Rehearse a refund that is accepted and later fails, and a dispute's life.
const refund = await payments.refund({ paymentReference: providerRef, idempotencyKey: "order-1:refund:1" });
payments.refundEvent(refund.id, "failed", "expired_or_canceled_card"); // a refund.failed event
payments.disputeEvent(providerRef); // dispute.opened (the payment becomes "disputed")
payments.disputeEvent(providerRef, "won"); // dispute.closed, outcome "won"

The fake cancels too: payments.cancelCheckout({ checkoutId: session.id, idempotencyKey }) expires a session nobody paid (its payment becomes canceled, so payments.eventFor(providerRef) is the payment.canceled a provider would send), answers paid for one that was paid and already_ended for one that is over, and payments.cancellations lists what it was asked, once per key.

To post a webhook of your own making to your own route (the whole path, the signature check included, with no provider at all), sign it with signStripeWebhook(body, secret, seconds?) or signMercadoPagoWebhook({ dataId, requestId, ts? }, secret): they make the Stripe-Signature and x-signature headers of each provider's documented scheme. This package's tests check them against an independent implementation and against the verifiers.

The fake honours idempotency keys (a repeated key replays the first answer), tracks cumulative refunds (a refund above the remaining balance is rejected), and signs its webhooks with HMAC-SHA256 in x-fake-signature.

In your own tests of an adapter-shaped call, recordedFetch (from @vibedout/payments/testing, where this package re-exports the provider kit's double) is the fetch this package's own tests use: it answers from fixtures in order, records every call (URL, headers, parsed body, the signal) and fails on a call nobody expected. Pass its fetch to either adapter.

Stripe (./stripe)

A PaymentsProvider over Stripe's REST API with fetch (form-encoded), no stripe npm package. It uses the project's own Stripe account: the keys are that project's Worker secrets, never ours.

import { createStripeProvider } from "@vibedout/payments/stripe";

const payments = createStripeProvider({
  secretKey: env.STRIPE_SECRET_KEY, // sk_… (or a restricted rk_… key): wrangler secret put STRIPE_SECRET_KEY
  webhookSecret: env.STRIPE_WEBHOOK_SECRET, // whsec_… of the project's webhook endpoint (only parseWebhook needs it)
  planPriceIds: { "plan-60mxn": "price_…" }, // only for kind: "subscription"
});

In a Worker whose secrets are named STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET, createStripeProviderFromEnv(env, options?) is the same thing read from the environment (every other option is the adapter's own, retry and fetch included). A missing secret is configuration_invalid and names the variable, and the one more setting it reads, PAYMENTS_API_BASE_URL (a local mock or stripe-mock standing in for Stripe; empty in a real project), only takes https, or http on this machine: the key goes wherever it says, so a typo or a hostile value cannot send it over the open internet.

Configure it (all in the project's own Stripe dashboard):

  1. Put STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in the Worker's secrets (.dev.vars locally; list them in wrangler.jsonc secrets.required so a deploy without them fails by name).
  2. Create a webhook endpoint at https://<app>/webhooks/payments/stripe subscribed to checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, checkout.session.expired, charge.refunded, refund.created, refund.updated, refund.failed, charge.dispute.created and charge.dispute.closed. Create it on the API version the adapter pins (STRIPE_API_VERSION, 2026-09-30.endive): event bodies follow the endpoint's version, requests follow the adapter's.
  3. Turn OXXO, bank transfers (SPEI) and installments on in Settings, Payment methods. With no methods given the dashboard decides what Checkout offers.

What each method needs:

| Method | methods | Notes | |---|---|---| | Card | "card" | MSI: installments: { enabled: true }. Stripe picks the eligible plans (3 to 24 months, Mexican credit cards of listed issuers, MXN, above each plan's minimum); maxMonths is a hint Stripe ignores, the plan list is dashboard configuration | | OXXO | "oxxo" | MXN only. extras.stripe.oxxoExpiresAfterDays 1 to 7 (default 5). Pays later: payment.requires_action, then payment.succeeded or payment.expired. Cannot be refunded | | SPEI | "spei" | customer_balance / mx_bank_transfer. Needs an existing Customer: extras.stripe.customerId (Checkout offers bank transfers only on a session with a customer). Refunds are asynchronous (status: "pending", up to 5 business days) |

OXXO refunds. Stripe cannot refund OXXO payments. refund looks the payment up first and throws PaymentsError code refund_not_supported (not retryable) without calling Stripe's refund endpoint; capabilities.refundableMethods leaves oxxo out so a UI can hide the button. Credit the customer another way and record it yourself.

Stopping a checkout (cancelCheckout). An open Checkout Session is expired (POST /v1/checkout/sessions/{id}/expire), one call. When Stripe refuses because the session is not open, the adapter reads it: an expired session is already_ended, a paid one is paid, and a completed session still unpaid is an OXXO voucher or a SPEI transfer waiting on the payer, whose PaymentIntent is cancelled (POST /v1/payment_intents/{id}/cancel, cancellation_reason: abandoned). Each write sends your key with a suffix (:expire, :intent), because Stripe scopes a key to one endpoint. A PaymentIntent that is processing cannot be stopped: invalid_request. Stripe then sends checkout.session.expired or payment_intent.canceled. The request shapes are the documented ones; cancelling the PaymentIntent of a completed Checkout Session is confirmed in the sandbox run.

Webhook events (parseWebhook, or the pure normalizeStripeEvent):

| Stripe event | Normalized | |---|---| | checkout.session.completed, payment_status: paid | payment.succeeded | | checkout.session.completed, unpaid (OXXO or SPEI issued) | payment.requires_action | | checkout.session.async_payment_succeeded | payment.succeeded | | checkout.session.async_payment_failed | payment.expired for OXXO/SPEI (the unpaid voucher lapsed), else payment.failed | | checkout.session.expired | payment.expired | | charge.refunded | payment.refunded (cumulative amountRefunded, status partially_refunded or refunded) | | charge.dispute.created | dispute.opened | | charge.dispute.closed | dispute.closed, outcome: status won is won, lost is lost, warning_closed and prevented are inquiry_closed. Any other status on a closed event is refused (invalid_response), not guessed | | refund.created, refund.updated | refund.succeeded for status succeeded; refund.failed for failed and canceled; refund.pending for pending, requires_action, a null or an unknown status (providerStatus keeps Stripe's word) | | refund.failed | refund.failed, with failureReason (expired_or_canceled_card, declined, ...) |

Not normalized (they return []): payment_intent.*, charge.refund.updated (deprecated, refund.updated covers every refund), charge.dispute.updated, charge.dispute.funds_withdrawn / funds_reinstated, subscription events and setup-mode Checkout Sessions (they save a payment method, no money moves). The OXXO-lapse mapping above rests on Stripe's docs ("expired or failed otherwise"); getPayment reports the same lapse as expired, so a reconciliation job and the webhook agree. Both are confirmed in the sandbox run, along with the pinned API version string, which is the one docs.stripe.com/api/versioning calls current on 2026-10-07: a wrong value would fail every call, loudly. Stripe's 409 idempotency_key_in_use (the first request with a key is still running) is a retryable unavailable, not already_processed.

Saved payment methods (listPaymentMethods)

const payments = createStripeProvider({
  secretKey: env.STRIPE_SECRET_KEY,
  // Your own mapping from a Vibedout account to its Stripe Customer, e.g. a D1 lookup written when the Customer was created:
  customerIdFor: async (accountId) => (await db.get("stripe_customers", accountId))?.customerId,
});
payments.capabilities.supportsSavedPaymentMethods; // true only because customerIdFor was given
const methods = await payments.listPaymentMethods({ accountId }); // [{ id: "pm_…", type: "card", brand: "visa", last4: "4242" }]

accountId must come from the verified session, never from a request parameter: the lookup turns it into a Stripe Customer, and a caller-chosen id would list someone else's cards. It pairs with extras.stripe.customerId on checkout: the same Customer id you pass to Checkout is the one customerIdFor returns. Read-only: GET /v1/customers/{id}/payment_methods, every type, all pages (100 at a time, at most 1,000 in all, then invalid_response rather than a silent cut). type is the package's kind where it has one (card, oxxo, spei: a saved customer_balance method is read as spei, which is right for Mexico's bank transfers), else Stripe's own type name (us_bank_account). An account customerIdFor has no Customer for gets []; a Customer Stripe no longer knows is Stripe's own not_found (a stale mapping). isDefault is not reported: the Customer's default and a subscription's own default live on other objects this call does not read. Without customerIdFor the method throws unsupported_operation before any call. Adding, removing or choosing a default method is not here: for "manage billing" send the customer to Stripe's hosted billing portal. A Checkout Session saves a method only in subscription mode or with setup_future_usage (extras.stripe.sessionParams); the field names and paging come from docs.stripe.com, not yet from a sandbox run .

getPayment(ref) takes the PaymentIntent id (pi_…, the providerRef events report) or the Checkout Session id (cs_…) and returns the voucher URL, OXXO number or SPEI CLABE while the payment waits. refund({ paymentReference }) takes the PaymentIntent id.

Mercado Pago (./mercadopago)

A PaymentsProvider over Mercado Pago's REST API with fetch, no mercadopago SDK (the SDK needs nodejs_compat and makes RANDOM idempotency keys by default). It uses the project's own Mercado Pago account: the credentials are that project's Worker secrets, never ours.

import { createMercadoPagoProvider } from "@vibedout/payments/mercadopago";

const payments = createMercadoPagoProvider({
  accessToken: env.MP_ACCESS_TOKEN, // APP_USR-… (TEST-… for a test account): wrangler secret put MP_ACCESS_TOKEN
  webhookSecret: env.MP_WEBHOOK_SECRET, // the app's "Clave secreta" (only parseWebhook needs it)
  notificationUrl: "https://<app>/webhooks/payments/mercadopago", // set on every preference; it wins over the dashboard URL
});

createMercadoPagoProviderFromEnv(env, options?) reads MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET (and PAYMENTS_API_BASE_URL, with the guard described under Stripe) from the Worker's environment, and takes the adapter's other options (notificationUrl, retry, fetch).

Configure it (in the project's own Mercado Pago developer dashboard):

  1. Create an application, copy its access token, and in Tus integraciones, Webhooks copy the secret key and subscribe the Pagos (payment) topic.
  2. Put MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET in the Worker's secrets (.dev.vars locally; list them in wrangler.jsonc secrets.required).
  3. Turn meses sin intereses on in the seller account (Tu negocio, Costos, Checkout): installments: { enabled: true, maxMonths: 6 } only caps the plan length the preference offers.

How it behaves:

  • createCheckout creates a Checkout Pro preference and returns its init_point. Amounts are integer centavos here and decimal pesos on the wire (4599 becomes unit_price: 45.99); the account currency is MXN unless currency says otherwise. Always pass referenceId: it becomes the preference's external_reference, which is how confirmPayment finds the payment and how your records line up. back_urls.failure is your cancelUrl (Checkout Pro has no separate cancel return), and pending is your successUrl (where an OXXO or SPEI payer lands after getting the voucher). Subscriptions are not supported (preapproval).
  • methods restrict the checkout by excluding the known Mexican payment types nobody asked for: card (credit, debit, prepaid), oxxo (type ticket, which also admits other cash vouchers such as Paycash), spei (bank_transfer), wallet (account_money). A type Mercado Pago adds later is not excluded until the adapter learns it: for an exact allow-list use extras.mercadopago.preference.payment_methods. Anything else a preference takes (date_of_expiration, binary_mode, payer.identification) goes in extras.mercadopago.preference.
  • cancelCheckout stops a checkout: with the paymentReference a webhook named, a payment that is pending or in_process (an OXXO ticket or a SPEI transfer waiting on the payer) is cancelled (PUT /v1/payments/{id}, status: "cancelled"), and one already approved, authorized, in_mediation, charged_back or refunded is paid and left alone. The preference is then expired by moving its window to a time that has passed (PUT /checkout/preferences/{id}), the only way to stop a payer who has not started a payment, since Checkout Pro has no cancel call. Whether the preference accepts a window in the past is confirmed in the sandbox run; a refusal reaches the caller as the PaymentsError it is.
  • The webhook is thin (an id only). parseWebhook verifies x-signature, reads the payment named by the SIGNED data.id query parameter (the JSON body is not signed and is not trusted), fetches it, and maps its status:

| Mercado Pago status | Normalized | |---|---| | approved | payment.succeeded (plus a partial payment.refunded when part was refunded) | | pending | payment.requires_action (an OXXO voucher or SPEI transfer awaiting) | | in_process, authorized | payment.processing | | rejected | payment.failed | | cancelled, status_detail: expired | payment.expired (the unpaid voucher lapsed; expired per Mercado Pago's docs, to be confirmed in the sandbox) | | cancelled, other | payment.canceled | | refunded | payment.succeeded, then payment.refunded | | charged_back, in_mediation | payment.succeeded, then dispute.opened (one id per payment: a claim that goes in_mediation and later charged_back is one dispute) |

Mercado Pago emits no refund.* and no dispute.closed: its notification is the payment, and as far as the payment object in its reference shows, it does not say that a mediation was decided for the seller (an in_mediation payment that returns to approved looks like any approved one; unconfirmed until the sandbox run), so there is nothing to normalize an outcome from. A refund shows as payment.refunded with the cumulative amount; a lost claim as charged_back/refunded on the payment.

Event ids are derived from the payment's state (payment:<id>:approved, payment:<id>:refunded:<centavos>), because a thin notification has no per-fact id: payment.created and payment.updated notifications that resolve to the same state, and Mercado Pago's own retries, all dedupe. A status the adapter does not know is refused (invalid_response), never guessed. topic_chargebacks_wh and other topics return [] (their data.id is a chargeback id, not a payment id), so dispute.opened relies on Mercado Pago also sending a payment notification when the payment's status turns in_mediation or charged_back: unverified, to be confirmed in the sandbox run ; reconcile disputes with getPayment until then. The dashboard's "simulate notification" uses a fake id and answers not_found (not retryable).

  • Signed webhooks versus IPN. notificationUrl is set on every preference and wins over the dashboard's URL. Mercado Pago also has the older, UNSIGNED IPN notifications, which parseWebhook refuses (no x-signature). Whether a preference-level notification_url delivers the signed webhook format is unverified: confirm it in the sandbox run, and if it does not, configure the URL in the dashboard's Webhooks instead and leave notificationUrl unset.
  • refund is POST /v1/payments/{id}/refunds: no body for a full refund, an amount for a partial one. What Mercado Pago does with an OXXO or SPEI refund is unverified, so the adapter forwards the request and returns the provider's own answer.

Saved payment methods are not supported. Mercado Pago can list a Customer's cards (GET /v1/customers/{id}/cards, field names from its reference, not from a sandbox), but a card lands there only through the Customers and Cards API with a token made by MercadoPago.js, a flow this adapter does not use: as far as its documentation shows, Checkout Pro saves no card to the project's own account (the payer's cards live in the payer's own Mercado Pago wallet; unconfirmed until the sandbox run). A list would be empty by construction and read as "this payer has no saved methods", and the adapter could not charge a listed card anyway. A project that saves cards through Bricks or the Cards API can call that endpoint itself.

The x-signature ts unit (open question)

x-signature: ts=<ts>,v1=<hex> signs the manifest id:<data.id>;request-id:<x-request-id>;ts:<ts>; (an absent part is left out) with HMAC-SHA256 under the app secret. Mercado Pago's docs show a 13-digit ts (milliseconds); its own SDK reads ts as seconds. Which is right is settled by the sandbox run. Until then:

  • MERCADOPAGO_TS_UNIT is "milliseconds", the documented reading; timestampUnit: "seconds" overrides it per provider.
  • A wrong guess fails CLOSED: a real webhook is refused as stale, loudly, never accepted as a replay. The HMAC itself does not depend on the unit.
  • The package's tests pin both readings, so settling it is one constant and its test block.
  • The default freshness window is 14 days, on purpose. Mercado Pago retries an unanswered notification after 15 min, 30 min, 6 h, 48 h and 96 h (three times), and whether a retry carries the original ts is unverified: a tight window could refuse a legitimate retry, and a refused retry loses the confirmation. Replay protection is worth little here (the body is unsigned, the payment is re-fetched, event ids derive from its state); the window mostly catches a wrong unit, which is off by decades. Tighten webhookToleranceSeconds once the sandbox shows what a retry carries.

stripe/webhook

import { verifyStripeWebhookSignature } from "@vibedout/payments/stripe/webhook";

app.post("/webhooks/stripe", async (c) => {
  const { event } = await verifyStripeWebhookSignature({
    header: c.req.header("Stripe-Signature"),
    payload: await c.req.text(), // RAW body (or its bytes): never c.req.json() first
    secret: c.env.STRIPE_WEBHOOK_SECRET,
  });
  // ...narrow `event` and dispatch...
});

Throws webhook_signature_invalid (not retryable) on a missing, malformed or non-matching signature or a payload that is not JSON, and webhook_expired when the signature is genuine but the timestamp is outside the window (300 seconds unless toleranceSeconds says otherwise). Both are 4xx. Only v1 signatures are accepted; a secret-rotation header with two v1 values is accepted if either matches.

facturapi/client + cfdi/*

Automatic subscription invoicing: a Stripe invoice.payment_succeeded webhook issues one CFDI through Facturapi, method PUE, one per billing cycle.

import { createFacturapiClient } from "@vibedout/payments/facturapi/client";
import { D1CfdiIssuanceStore } from "@vibedout/payments/cfdi/issuance-store"; // tables: see cfdiIssuanceMigrationSql()
import { issueSubscriptionCfdiFromWebhookEvent, isInvoicePaymentSucceededEvent } from "@vibedout/payments/cfdi/subscription-cfdi";

if (isInvoicePaymentSucceededEvent(event)) {
  const result = await issueSubscriptionCfdiFromWebhookEvent(event, {
    facturapi: createFacturapiClient({ apiKey: c.env.FACTURAPI_API_KEY }),
    store: new D1CfdiIssuanceStore(c.env.DB),
    resolveBillingProfile: myAccountLookup, // your account data: null means no Mexican tax profile on file
    paymentForm: "03", // SAT catalog code: CONFIRM WITH THE ACCOUNTANT
    cfdiUse: "G03", // SAT catalog code: CONFIRM WITH THE ACCOUNTANT
    productKey: "81112101", // SAT catalog code: CONFIRM WITH THE ACCOUNTANT
  });
  // result.status: "issued" | "already_issued" | "skipped_no_billing_profile" | "in_progress"
}

The issuance store is a claim, not a find-then-write. The subscription flow sends no idempotency key, and Stripe delivers at least once with overlapping deliveries, so the guard is ours and it is race-free. The flow: look the key up; resolve the billing profile; claim stripe_invoice:<invoice id> (the shared receipt store's single atomic INSERT ... ON CONFLICT DO NOTHING); call Facturapi; complete the claim with the issued CFDI (one row in the records table). Of any number of concurrent deliveries one claims and issues; the others are told in_progress or already_issued without calling Facturapi. D1CfdiIssuanceStore is the production store (cfdiIssuanceMigrationSql() makes its two tables: cfdi_claims, deliberately not the webhook receipts table, so that no cron purge ever frees a CFDI claim, and cfdi_issuance; its SQL is tested on a real SQLite engine) and createMemoryCfdiIssuanceStore backs tests.

When the call fails: a refusal that provably did nothing (bad tax data, a bad key, a rate limit) releases the claim, so the next delivery tries again. After anything that may have issued the invoice (a timeout, a 5xx, a 200 that is not an invoice, an error of unknown kind) the claim stays and the error is thrown, because repeating the call could issue a second CFDI for the same cycle. The redelivery is told in_progress: answer it with a non-2xx status so Stripe tries again later (an in-flight delivery will have finished by then). Do not pass the HTTP request's own abort signal to this call: a client that hangs up mid-call is exactly the ambiguous case, and it would keep the claim; let the call run to its timeout (or bound it with your own signal). A signal that is already aborted before the claim leaves nothing behind.

A claim that stays in_progress needs a person: check Facturapi for the invoice's reference (the Stripe invoice id) and either store.complete({ key, cfdiInvoiceId, cfdiUuid, issuedAt }) with what you find, or store.release(key) if nothing was issued. Stuck claims are the ones with no record:

SELECT c.key, c.claimed_at FROM cfdi_claims c LEFT JOIN cfdi_issuance i ON i.key = c.key WHERE i.key IS NULL;

If recording a CFDI that was issued fails, the thrown error names its Facturapi id and UUID and the claim stays.

A sale's invoice, its credit note and the files (facturapi/client, cfdi/sale-invoice)

The client also reads an invoice back (getInvoice), finds one by the id the caller stamped into external_id (findInvoiceByExternalId), downloads the PDF or the XML as exact bytes (downloadFile, capped at 5 MiB) and cancels with the SAT's motive (cancelInvoice(id, { motive, substitution? }); 01 and 04 need the UUID of the replacement; the SAT may leave the cancellation pending until the receiver accepts, so read status and cancellationStatus). The key is sent as Authorization: Bearer <key>. Facturapi's refusals come back as a PaymentsError whose issues name the field (customer.tax_id) and carry Facturapi's own words.

cfdi/sale-invoice is pure: a sale's lines go in (each with what one unit was paid, whether the IVA is inside the price, the SAT keys, the IVA treatment as rate, zero or exempt, and the discount it got) and the input of createInvoice comes out: buildIncomeInvoice, buildCreditNote (an egreso, use G02, related to the invoice by UUID; a refund of everything repeats the invoice's concepts, a part refund is one concept per tax treatment, shared in proportion) and buildGlobalInvoice (the generic receiver, use S01, the period). expectedTotalMinor says what the PAC should total, so a caller can compare it with Facturapi's own totalMinor and see a centavo of difference instead of assuming there is none. cfdi/rfc checks the shape of an RFC (not the SAT's list, and not the check digit) and knows the two generic RFCs; cfdi/catalogs has the régimen, uso, forma de pago, cancellation motive and credit relation codes with their words in en and es-MX.

A receiver abroad and a currency other than pesos. foreignReceptor({ legalName, country, taxId?, email?, issuerZip }) is the receiver of an invoice to someone abroad with no Mexican RFC: the foreign generic RFC XEXX010101000, régimen 616, use S01, the name they give and the issuer's own postal code as the domicilio; country is an ISO 3166-1 alpha-3 code (isForeignCountry, FOREIGN_COUNTRY_CODES, countryName(code, locale) in cfdi/catalogs, which holds the ISO list, not the SAT's c_País) and taxId the receiver's number in its country (isForeignTaxId). The invoice and its credit note then carry exportKey: "01" ("No aplica") and the client sends the customer as Facturapi's "Cliente extranjero" (address.country and, when there is one, the foreign number as tax_id). An invoice in a currency other than MXN takes exchangeRate (pesos per unit of the currency, a text of at most six decimals, parseExchangeRate reads what a person typed) and the client sends it as Facturapi's exchange; without one it throws before any request, because Facturapi's default is 1 and would state a dollar at a peso. The package asks no one for a rate: the caller says where it came from. mxnEquivalentMinor(amount, currency, rate) says what an amount is worth in pesos, in whole numbers, to show a person. To confirm with the accountant and in a sandbox: every rule above is an assumption, listed in the invoicing feature's skill; none was run against Facturapi.

createMemoryFacturapiClient() (in facturapi/fixture-client) is a small Facturapi in memory for tests of a feature built on these: every invoice has its own id and UUID, the idempotency key is honoured, a credit note must relate to an invoice that exists, files can be downloaded, a cancellation follows the motives (and can wait for the receiver), and failNext("create", error, { issued: true }) scripts the call that fails after the invoice was issued.

To confirm in a Facturapi sandbox (the shapes come from its public API reference; none was ever called): what a repeated idempotency_key answers (the first invoice, or a 409); that related_documents[].documents takes the UUIDs; the spelling of the global periods and of an exempt concept (factor: "Exento"); that a concept's discount is taken off a tax-inclusive total; that a file download is not redirected.

Founder and accountant checklist before this can issue a real CFDI (the code is built and tested against recorded fixtures and has never made a live call):

  • A live Facturapi account (sandbox first) and its API key.
  • The exact SAT catalog codes for paymentForm (c_FormaPago), cfdiUse (c_UsoCFDI), productKey (c_ClaveProdServ) and the subscriber's own taxSystem (c_RegimenFiscal), and the IVA and receptor-type handling, confirmed with the accountant.
  • Confirmation that Facturapi's invoice-item price really is decimal pesos (this package assumes so, per Facturapi's public docs).
  • For a sale's invoice and credit note: the SAT codes the sale builders do not decide (which goods are at 0% or exempt, the relation 01 or 03 of a refund's credit note, the generic key of a part refund), and the rounding of a tax-inclusive price, confirmed with the accountant against a real CFDI.

Built on shared kits

Nothing in this package writes its own HMAC, constant-time compare, signature-header parsing, freshness check, HTTP client, timeout, abort plumbing, retry, secret redaction, error base class, idempotency check, money conversion, receipt store or JSON reader. They are @vibedout/web-crypto, @vibedout/money and @vibedout/provider-kit, bundled into this package's build, and each adapter here is the provider kit's createHttpClient plus a mapError hook plus its own mapping.

The rule

Every payments touchpoint (a checkout, a deposit, a subscription) depends on PaymentsProvider (an interface), never on a concrete processor client directly. Swapping a processor is then one line where the provider is built, and tests run on the in-memory fake.

Provenance

The Stripe and Mercado Pago field names were checked against docs.stripe.com and the official [email protected] SDK source; fixtures are handwritten, since no account exists to record from. Everything marked "unverified" or "the sandbox run" is settled by the first run against each processor's sandbox account.

Changelog

  • 0.3.3: a discount says which items it came off. CheckoutAdjustment.lines (CheckoutAdjustmentLine: index into items, amountMinor) on a discount; applyAdjustments takes the named discounts off exactly the items they name and shares the rest of the discounts (the ones that name none) over what is left of every item, in proportion, one after the other (each over what the one before left), with the same largest-remainder rule as before. A project that names nothing and sends one discount sends the same lines as before; two discounts that name nothing may differ by a centavo on a line, because they are now shared as @vibedout/commerce's attributeDiscounts shares them (tested centavo for centavo). Refused as invalid_request: an index that is not an item, a repeated one, amounts that do not add up to the discount, an item discounted by more than it costs, and lines on a shipping or tax adjustment. Additive: a caller written for 0.3.2 changes nothing. A patch number on purpose: the blocks that list this package keep one range between them (now ^0.3.3), and payments and invoicing move together.

  • 0.3.2: a receiver abroad and an invoice in another currency. cfdi/sale-invoice: foreignReceptor, CfdiReceptor.foreign, EXPORT_NOT_APPLICABLE, parseExchangeRate, mxnEquivalentMinor, and exchangeRate on the builders of an invoice and a credit note; cfdi/rfc: FOREIGN_RECEIVER_REGIME, FOREIGN_RECEIVER_USE, isForeignTaxId; cfdi/catalogs: COUNTRY_ALPHA2, FOREIGN_COUNTRY_CODES, isForeignCountry, countryName, MEXICO_ALPHA3; facturapi/client: FacturapiCustomerInput.foreign, CreateCfdiInvoiceInput.exchangeRate and exportKey, CfdiInvoice.exchangeRate, and a foreign-currency invoice without a rate is refused before a request (the in-memory double does the same and echoes the rate). Additive: an invoice in pesos for a Mexican receiver is built and sent exactly as before. A patch number on purpose: the blocks that list this package keep one range between them (^0.3.1), and the invoicing feature needs 0.3.2 (pnpm update @vibedout/payments). None of the shapes was run against Facturapi.

  • 0.3.1: the Facturapi client reads an invoice, finds it by external_id, downloads its PDF and XML, cancels with the SAT's motive and issues credit notes (type: "E", related_documents) and global invoices; its key is now sent as a Bearer token (Facturapi's documented scheme) and the caller's reference as external_id; idempotencyKey is sent as idempotency_key. New: cfdi/rfc, cfdi/catalogs, cfdi/sale-invoice and the in-memory Facturapi double. The API of the package is additive (FacturapiClient.createInvoice and the subscription flow read as before), but what goes on the wire changed to the documented names: Bearer instead of Basic, and external_id instead of the old reference field; a caller that pointed a stand-in at the old names must follow (the provider kit's HTTP client gained responseType: "bytes" for the files). Nothing here has run against a live Facturapi account. To confirm there: what a repeated idempotency_key answers, that GET /invoices?q=<external_id> finds an invoice by its external_id (the invoicing feature's recovery of a lost answer depends on it), related_documents taking UUIDs, the global invoice's periodicity spellings, factor: "Exento", and whether a file download redirects.

  • 0.3.0: PaymentsProvider.cancelCheckout? (with CancelCheckoutInput, CancelCheckoutResult and CancelCheckoutOutcome): stops a checkout from taking money. Stripe expires the Checkout Session and cancels the PaymentIntent of an OXXO voucher or SPEI transfer waiting on the payer; Mercado Pago cancels a pending payment and expires the preference (its HTTP client now sends PUT); the fake cancels, replays by key and records cancellations. Additive: an adapter and a caller written for 0.2.0 change nothing.

  • 0.2.0: CreateCheckoutInput.adjustments (shipping, tax and discounts, shown as exact lines) and expiresAt (Stripe expires_at, Mercado Pago expiration_date_from/to), CheckoutSession.expiresAt; the fake records the expiry. Additive: a 0.1.0 caller changes nothing.

  • 0.1.0: the first release. A processor-independent interface; Stripe and Mercado Pago adapters; a Facturapi (CFDI) client and the subscription-invoice orchestration with a claim-based issuance store; webhook handling with receipt-store dedupe (handlePaymentWebhook); an in-memory fake; retry on both adapters; createStripeProviderFromEnv and createMercadoPagoProviderFromEnv; ./testing helpers (recordedFetch, hangUntilAborted, signStripeWebhook, signMercadoPagoWebhook).