Skip to content

@guestcraft/stripe

GuestCraft’s AGPL adapter from Stripe’s HTTP API v1 to the core port PaymentProvider (@guestcraft/core/payments). server composes it with the workspace’s own restricted key, opened for each call. Core owns permissions, transactions, idempotency keys, reconciliation and posting; this package owns bounded HTTP calls, money conversion, response validation and webhook signature verification. It has no Stripe SDK and uses Web Crypto, so it runs on any runtime.

import { stripeProvider } from "@guestcraft/stripe";
const provider = stripeProvider(); // or stripeProvider({ fetch: simulator.fetch })

Options: fetch (injected transport, for the simulator), baseUrl (default https://api.stripe.com/v1), timeoutMs (default 8,000 per attempt), apiVersion (default 2026-09-30.endive, sent as Stripe-Version on every request; change it only together with the schemas and the simulator) and now (the clock that checks a checkout’s lifetime).

Port operation Stripe endpoint
verify GET /v1/account, after a local check that the key is restricted
createCustomer POST /v1/customers (GuestCraft markers only, no personal data)
createCheckout POST /v1/checkout/sessions, payment or setup mode, cards only
expireCheckout POST /v1/checkout/sessions/{session}/expire
charge POST /v1/payment_intents with off_session=true, confirm=true; no customer for a method saved to none
capture POST /v1/payment_intents/{intent}/capture with amount_to_capture
cancel POST /v1/payment_intents/{intent}/cancel
refund POST /v1/refunds with payment_intent and amount
readCheckout GET /v1/checkout/sessions/{session}
readIntent GET /v1/payment_intents/{intent}, expanding latest_charge and payment_method
readSetup GET /v1/setup_intents/{intent}, expanding payment_method
readPaymentMethod GET /v1/payment_methods/{payment_method}; another account’s or mode’s method is refused (resource_missing)
readRefund GET /v1/refunds/{refund}
readDispute GET /v1/disputes/{dispute}
findCustomer GET /v1/customers?created[gte]=…, matching metadata.guestcraft_request
findCheckout GET /v1/checkout/sessions?customer=…&created[gte]=…, matching metadata.guestcraft_session
findCharge GET /v1/payment_intents?customer=…&created[gte]=… (without customer for a charge of no customer), matching metadata.guestcraft_payment
findRefund GET /v1/refunds?payment_intent=…, matching metadata.guestcraft_refund
parseEvent Stripe’s manual Stripe-Signature verification, no request

Markers travel as the metadata keys guestcraft_request, guestcraft_payment, guestcraft_refund and guestcraft_session; client_reference_id is the request marker. A find reads at most ten pages of 100 and answers unavailable (search_incomplete) rather than “not found” when it could not read them all.

Stripe’s Refund object has no livemode (schema refund), and a key reads only objects of its own mode (keys): a refund’s livemode is the mode of the key that created or read it (rk_live_/sk_live_). Disputes, intents, setups and sessions carry their own.

Every POST is form-encoded with Stripe’s bracket notation and carries the port’s Idempotency-Key; GETs never do. Redirects are refused. Writes are never retried; a GET is retried once after a network failure or a 5xx. Messages never contain the key, a request body or an upstream body: message is only the text of a card error, capped at 200 characters.

Answer POST GET
400, 402, 404, 409 or another 4xx with a Stripe error refused with code (decline_code, else code) and param same
401, 403 unauthorized unauthorized
429 unavailable (not executed) unavailable
5xx unknown (Stripe may have executed it and keeps the 500 under the key) unavailable
network failure, timeout, invalid JSON, unexpected shape unknown unavailable

An OTA booking’s card arrives as a PaymentMethod of the account saved to no customer (Channex’s Stripe tokenization app creates it in the property’s account). readPaymentMethod checks that the key’s account has it, and charge with customerId: null confirms it off-session without customer: customer is the customer the intent belongs to “if one exists”, and only a method attached to a Customer requires that customer (PostPaymentIntents, customer and payment_method). Neither the spec nor the API reference documents whether such a method can be charged more than once; Stripe describes Customer objects as what “let you reuse payment methods” (payment methods), so a second charge of the same method may be refused by a live account (not verified).

A declined charge answers 402 with the PaymentIntent in error.payment_intent; the adapter answers that intent (status requires_payment_method, with failure), because it exists.

verify refuses any key that does not start with rk_test_ or rk_live_ (code key_not_restricted): secret (sk_) and publishable (pk_) keys are not accepted. The mode comes from the prefix. A successful verify proves the account read only, never a write permission. Create the restricted key in the Stripe Dashboard with these permissions (restricted keys); everything else None:

Resource Permission Used by
Account Read verify
Customers Write createCustomer, findCustomer
Checkout Sessions Write createCheckout, expireCheckout, readCheckout, findCheckout
Payment Intents Write charge, capture, cancel, readIntent, findCharge
Setup Intents Read readSetup
Payment Methods Read readPaymentMethod, the card of an intent or setup (expanded payment_method)
Refunds Write refund, readRefund, findRefund
Disputes Read readDispute
Charges Read the hold expiry and card of an intent (expanded latest_charge)

The port speaks ISO 4217 minor units; the adapter converts exactly, following Stripe’s currencies page (fetched 2026-11-01):

  • two-decimal currencies by default: 10.50 EUR (1050) is 1050;
  • Stripe’s zero-decimal currencies are sent in whole units: 500 JPY is 500; MGA, two decimals in ISO, is sent in whole ariary and a fraction is refused;
  • ISK and UGX, zero-decimal in ISO, are sent as two-decimal values ending in 00: 5 ISK is 500, and an answer such as 550 has no ISO equivalent and is rejected;
  • capabilities.supportsCurrency follows Stripe’s list of presentment currencies for cards, embedded in src/money.ts. Three-decimal currencies (BHD, JOD, KWD, OMR, TND) are not on it and are refused with currency_not_supported;
  • an amount that is zero, negative, longer than twelve digits or not exactly representable is refused with amount_not_representable; nothing is ever rounded. Currency codes are lower case only on the wire.

parseEvent follows “Verify webhook signatures manually” (webhooks): the t= timestamp and every v1= signature of Stripe-Signature (other schemes, v0 included, are ignored), HMAC-SHA256 of ${t}.${body} over the exact bytes received with the whsec_ secret, a constant-time comparison, and a tolerance of 300 seconds between t and now (stale). The body is then parsed (malformed without id, type, livemode or data.object.id), and only the event id, type, mode and a reference to its object are returned; processing reads the object again.

Payment methods other than cards, delayed (asynchronous) methods, extended authorizations, multicapture, incremental authorizations, Stripe Connect, Payment Links and webhook-endpoint administration through the API.