@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 mapping
Section titled “Port mapping”| 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.
Requests and failures
Section titled “Requests and failures”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.
Restricted key
Section titled “Restricted key”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) is1050; - 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 is500, and an answer such as550has no ISO equivalent and is rejected; capabilities.supportsCurrencyfollows Stripe’s list of presentment currencies for cards, embedded insrc/money.ts. Three-decimal currencies (BHD, JOD, KWD, OMR, TND) are not on it and are refused withcurrency_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.
Webhooks
Section titled “Webhooks”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.
Not supported
Section titled “Not supported”Payment methods other than cards, delayed (asynchronous) methods, extended authorizations, multicapture, incremental authorizations, Stripe Connect, Payment Links and webhook-endpoint administration through the API.