@voila.dev/stripe
A light Stripe REST client on Effect: a pinned API version, nested form encoding, retries only when safe, Stripe's errors read (created nothing? card declined?), field readers, the webhook event schema and a scripted fake.
Stripe’s official SDK is large, typed against the latest API version, and leaves the hard questions to you: after a failed charge, does a payment intent exist at Stripe or not? Is a declined card an incident or the payer’s bank saying no? Is this POST safe to retry? This package is a small REST client on Effect that answers them, and leaves the objects as the JSON Stripe sends, read with a few typed field readers.
bun add @voila.dev/stripe @voila.dev/http effectA call
import {
rejectDeclinedCards,
requireId,
Stripe,
stripeFromConfig,
} from "@voila.dev/stripe";
import { Effect } from "effect";
const charge = (orderId: string, amount: number, paymentMethod: string) =>
Effect.gen(function* () {
const stripe = yield* Stripe;
const intent = yield* stripe
.request("POST", "/payment_intents", {
form: {
amount,
currency: "eur",
payment_method: paymentMethod,
confirm: true,
metadata: { order: orderId },
},
idempotencyKey: `charge-${orderId}`,
})
.pipe(rejectDeclinedCards);
return yield* requireId(intent, "POST /payment_intents");
});
charge("42", 1500, "pm_card_visa").pipe(
Effect.provide(stripeFromConfig({ apiVersion: "2026-09-30.endive" })), // reads STRIPE_SECRET_KEY
);- A pinned API version. Every call sends
Stripe-Version; the account default, which anyone can raise from the dashboard, never applies. - Forms in Stripe’s notation.
formandquerytake nested objects and arrays (metadata[order],expand[0]);nullunsets a field. - Retries only when safe. A GET, or a POST with an
idempotencyKey, is retried on a 5xx, a 409, a timeout or a dropped connection, and on a 429 with a longer backoff. A POST without a key is never retried: it could act twice. - Errors that say what happened. A
StripeErrorcarries thestatus, Stripe’stype,codeanddeclineCode, andcreatedNothing: true only when Stripe refused the request before acting (a 4xx, not a 409, not anidempotency_error). After anything else, look before you create again. - Declines are not incidents.
rejectDeclinedCardsturns acard_errorinto aStripeCardDeclinedErrorwith the issuer’s reason, on the calls that charge — a listing never carries a refusal in its type. - Each attempt is bounded by 15 seconds and runs in a span named after the
call with its ids replaced (
outbound.stripe.POST /payment_intents/:id/confirm). WithrecordingFetchLayerfrom@voila.dev/http, every request lands in your request log.
Reading objects
stringField, numberField, booleanField answer an Option, so “Stripe
said 0” and “Stripe said nothing” stay apart. objectField reads an expanded
object, objectIdField an id whether the object was expanded or not,
listData the objects of a list, metadataField the string metadata.
Webhooks
Check the signature on the raw body, then decode the envelope:
import { verifyStripeSignature } from "@voila.dev/http/webhooks";
import { decodeStripeEvent } from "@voila.dev/stripe";
const payload = await request.text();
if (!(await verifyStripeSignature({ payload, header: request.headers.get("stripe-signature") ?? undefined, secret, nowSeconds: Math.floor(Date.now() / 1000) }))) {
return new Response(null, { status: 400 });
}
const event = decodeStripeEvent(JSON.parse(payload)); // Option<StripeEvent>: id, type, created, data.object…In tests
import { scriptedStripe } from "@voila.dev/stripe";
const stripe = scriptedStripe();
stripe.on("POST", "/payment_intents", ({ options }) => ({ id: "pi_test", amount: options.form?.amount }));
program.pipe(Effect.provide(stripe.layer));
stripe.calls; // every request, with its formA call with no scripted answer fails as Stripe’s resource_missing would.
License
MIT