Osy#betaRun it on Osyrin: osyrin.com

Kits · Payments

Stripe

Card payments through Stripe — payment intents, checkout sessions, customers, refunds, verified webhooks, and a payments-contract provider.

Package
Osysharp.Payments.Stripe
Install
use Osysharp.Payments.Stripe@0 {
  egress "api.stripe.com";
}
Group
Payments
Tests
44 in the kit’s own suite
Builds on
Payments
Source
github.com/osysharp/stripe

Take money through Stripe — hosted Checkout or your own card form, customers, refunds, and verified webhooks — from classes written entirely in Osy# over the public Http.* surface.

Use case

Any app that charges for something: a shop checking out a basket, an invoice with a "pay now" button, a booking that takes a deposit and captures it on arrival, a subscription sign-up. The app says what to charge and for what; Stripe does the card handling, the 3-D Secure, the Apple Pay and the PCI surface.

It has no user interface of its own — the payment page is Stripe's, and you redirect to it — so there is nothing to screenshot. What you see is Stripe's checkout.

What the package door buys a payments integration

npm i stripe gives you a library with unrestricted network access and a transitive dependency graph nobody reads. This kit declares the one host it may reach, and an app that uses it grants that host or does not compile:

osy

package Osysharp.Payments.Stripe { egress "api.stripe.com"; }

There is no expression in Osy# that lets the package reach anywhere else. For the one integration in your app that handles money, "it can provably only talk to Stripe" is a property worth having mechanically rather than by review.

It asks for no secret. The API key is a setting on the client, so your app names its own — this package never learns which.

Install and use

osy

// app.osy
app Shop {
  model "**/*.osy";
  use Osysharp.Payments.Stripe@0 { egress "api.stripe.com"; }
}

Hosted Checkout — the short path

osy

using Osysharp.Payments.Stripe;

app.Secrets = [ new Secret("StripeApiKey"), new Secret("StripeWebhookSecret") ];

// ONE factory. It returns the client, key and all — legal, because nothing can read `ApiKey` back out of it: not a
// page, not a request, not a log line (the compiler tracks where the secret went).
StripeClient Stripe() { return new StripeClient { ApiKey = Secret.StripeApiKey }; }

string CheckoutUrl(Order order) {
  var session = Stripe().CreateCheckoutSession(new CheckoutSessionRequest {
    LineItems  = [new CheckoutLineItem { ProductName = $"Order {order.Reference}",
                                         UnitAmount = order.TotalMinor, Currency = "sek" }],
    SuccessUrl = $"{App.BaseUrl}/orders/{order.Reference}/{{CHECKOUT_SESSION_ID}}",
    CancelUrl  = $"{App.BaseUrl}/orders/{order.Reference}",
    ClientReferenceId = order.Reference,   // ← comes back on the session AND the webhook: which order was paid
  }, order.Reference);           // ← the idempotency key: a retry creates ONE session, not two
  return session.Url ?? "";
}

The webhook — and this is the part that is not optional

osy

[AllowAnonymous] void StripeHook(string body, string signature) {
  var e = new StripeWebhook { SigningSecret = Secret.StripeWebhookSecret }.Verify(body, signature);
  // A test event must not fulfil a live order — compare the event's mode with your KEY's, rather than dropping every
  // test event, or nothing in test mode can ever complete.
  if (e.Livemode != Secret.StripeApiKey.StartsWith("sk_live_")) { return; }
  if (e.Type == "checkout.session.completed" && e.Object?.PaymentStatus == "paid") {
    Fulfil(e.Object?.ClientReferenceId ?? "");
  }
}

The endpoint is public and unauthenticated — Stripe cannot log in — so the signature is the authentication. Without Verify, that route reads "anyone on the internet may mark any order paid", and an unverified handler passes every functional test you will write for it.

Verify throws ValidationException (answer 400) when the request is not provably Stripe's: a tampered body, the wrong secret, a missing or malformed header, or a signature old enough to be a replay. It accepts a header carrying several v1= signatures, which is what an endpoint receives while a signing secret is being rotated.

⚠ Pass the RAW request body. The signature covers the bytes Stripe sent; a body that has been parsed and re-serialized has different whitespace and key order, and fails to verify for reasons that look nothing like the cause.

A card reader at a shop's counter

StripeTerminal takes a card on a Stripe Terminal smart reader (Stripe Reader S700, BBPOS WisePOS E), driven from the server as Stripe describes (https://docs.stripe.com/terminal/payments/collect-card-payment?terminal-sdk-platform=server-driven): a card_present payment intent handed to the reader (process_payment_intent), paid once the intent has succeeded for this sale's reference and amount. It implements ICounterPayment; list it in the shop's CounterPayments beside the StripePayments that refunds it. A reader takes its account's local currency (Currencies). Without hardware, register a simulated reader (registration code simulated-wpe) with a test key and call PresentTestCard() where a customer would tap (https://docs.stripe.com/terminal/references/testing).

osy

new StripeTerminal { ApiKey = Secret.StripeApiKey, ReaderId = "tmr_…", Currencies = ["SEK"] }

Three things that bite everyone

Amounts are integer minor units2000 is 20.00, never 20. The API takes long for exactly that reason — there is no rounding decision to get wrong because there is nothing to round.
Stripe takes FORM bodies and answers JSONIt will not accept JSON. A JSON request comes back as a complaint about a missing parameter, which reads like you forgot a field. FormBody does the encoding, brackets and all.
A declined card is a 200Only transport and configuration faults throw. A refused card comes back successfully with Status = "requires_payment_method". Read Status — a caller that only checks for an exception has written a flow that treats a decline as a sale.

What is in it

StripeClientpayment intents (create · get · capture · cancel), hosted Checkout sessions (create · get), customers, refunds
StripeWebhookHMAC-SHA256 signature verification with a replay window and rotation support
FormBodyStripe's form encoding — nested brackets, indexed lists, sorted metadata
StripeWirethe response shapes, with every id mapped through [ExternalName]

Pin your API version once you are live

ApiVersion is empty by default, which means your account's default. Stripe moves that when you upgrade in the dashboard, and it changes response shapes under a running app with no deploy on your side. This kit reads only long-stable fields so it does not pin one on your behalf — but you should:

osy

new StripeClient { ApiKey = Secret.StripeApiKey, ApiVersion = "2025-08-27.basil" }

Source and tests

Everything is in model/ — four files, no C# anywhere. tests/ is the producer's own project: tests that assert what went on the wire rather than that a call returned, including the whole webhook-forgery surface. It does not ship, and osy publish runs it and refuses a version that fails.

Get it

Add the kit to your app’s manifest:

osy

app MyApp {
  use Osysharp.Payments.Stripe@0 {
    egress "api.stripe.com";
  }
}

Then run osy lock: it downloads the release, verifies it by its content address and pins that version in osyrin.lock. osy kits Osysharp.Payments.Stripe shows what the kit is for, from inside your app.

Its tests are its specification, and they come with its source. In a copy of github.com/osysharp/stripe, osy kits Osysharp.Payments.Stripe lists the test app and every test, and cd tests && osy test runs them.