An unofficial, fully typed, production-ready .NET SDK for the Bachs payments and billing API. Not built, maintained, or endorsed by Bachs itself — see Contributing if you'd like to help maintain it. Covers every documented resource — products, pricing, checkout, customers, subscriptions, refunds, payouts, disputes, currency conversions, and webhooks — built on Refit with built-in resilience (retry + circuit breaker), structured logging, idempotency support, and a non-throwing result type for API failures.
- Targets .NET 8, .NET 9, and .NET 10 - multi-targeted, so a consuming app gets the assembly (and runtime performance characteristics) matching its own target framework, not a lowest-common-denominator build. See Design notes.
- Every public type and member has XML doc comments — full IntelliSense, no need to tab back to the browser.
- Generated directly from Bachs's OpenAPI spec (
docs/openapi/openapi.json) for the request/response models, so field names and nullability track the API exactly; the client layer, resilience, and webhooks are hand-written.
Bachs itself is under active development. Several resources (Subscriptions, Payouts, Refunds, Disputes, Conversions, Connected Accounts) are marked Beta or Limited Access in Bachs's own docs — see the error reference below for how that surfaces, and contact
hello@bachs.ioto request access.
- Install
- Quickstart
- Configuration — sandbox vs. production, custom base URLs, options
- Dependency injection
- The result pattern — how errors work
- Idempotency
- Resilience: retries, circuit breaker, and detecting "Bachs is unreachable"
- Logging
- Products and pricing
- Checkout sessions
- Frontend integration (hosted redirect vs. overlay checkout)
- Customers
- Subscriptions, trials, and proration
- Refunds
- Payouts
- Disputes
- Currency conversions
- Pagination
- Webhooks
- Design notes
- Contributing, building, and publishing
dotnet add package Bachs.Netusing Bachs.Net;
using var client = BachsClient.Create(new BachsClientOptions
{
ApiKey = Environment.GetEnvironmentVariable("BACHS_API_KEY")!, // sk_sandbox_... or sk_live_...
Environment = BachsEnvironment.Sandbox,
});
var result = await client.CheckoutSessions.CreateAsync(new CreateCheckoutSessionRequest
{
ProductCart = [new ProductItemRequest { ProductId = "prod_abc123", Quantity = 1 }],
Customer = new NewCustomerRequest { Email = "customer@example.com", Name = "Jane Doe" },
SuccessUrl = "https://yoursite.com/success",
CancelUrl = "https://yoursite.com/cancelled",
});
if (result.IsSuccess)
{
Console.WriteLine($"Send the customer to: {result.Value!.CheckoutUrl}");
}
else
{
// result.Error is populated for a real API rejection (bad request, validation, etc).
// result.Exception is populated instead when Bachs was unreachable (see "Resilience" below).
Console.WriteLine($"Checkout creation failed: {result.FailureSummary()}");
}Never fulfil an order from a client-side redirect. Treat the collection.succeeded webhook as the source of truth — see Webhooks.
var options = new BachsClientOptions
{
ApiKey = "sk_sandbox_...",
Environment = BachsEnvironment.Sandbox, // Sandbox (default), Production, or Custom
Timeout = TimeSpan.FromSeconds(30),
// Resilience (see below) - all optional, these are the defaults:
EnableRetries = true,
MaxRetryAttempts = 3,
RetryBaseDelay = TimeSpan.FromMilliseconds(200),
EnableCircuitBreaker = true,
CircuitBreakerFailureThreshold = 5,
CircuitBreakerDuration = TimeSpan.FromSeconds(30),
};| Environment | Base URL | Key prefix |
|---|---|---|
BachsEnvironment.Sandbox (default) |
https://sandbox-api.bachs.io |
sk_sandbox_... |
BachsEnvironment.Production |
https://api.bachs.io |
sk_live_... |
BachsEnvironment.Custom |
options.BaseUrl (required) |
not validated |
Key/environment mismatch protection. By default, BachsClientOptions.Validate() (called automatically) checks that your API key's prefix matches the configured environment, and throws BachsConfigurationException if they disagree. This exists to catch two easy-to-make mistakes: a live key accidentally pointed at sandbox (annoying), and — worse — a sandbox key accidentally pointed at production, which typically just fails outright rather than silently doing the wrong thing, but the reverse (forgetting to swap environments when you do have a live key) is the dangerous direction. Set ValidateApiKeyPrefix = false only if you're proxying through something that rewrites keys.
Manual base URL override. For a local mock server, a staging proxy, or anything else that isn't literally sandbox-api.bachs.io / api.bachs.io, use Environment = BachsEnvironment.Custom with BaseUrl set — this is the "manual URL override" path:
var options = new BachsClientOptions
{
ApiKey = "sk_sandbox_...",
Environment = BachsEnvironment.Custom,
BaseUrl = "https://localhost:5443", // e.g. a WireMock instance in tests
ValidateApiKeyPrefix = false,
};In an ASP.NET Core / generic host app, register via AddBachs — this wires every resource through IHttpClientFactory (pooled connections, picks up DNS changes) and reuses your app's ILoggerFactory:
builder.Services.AddBachs(options =>
{
options.ApiKey = builder.Configuration["Bachs:ApiKey"]!;
options.Environment = builder.Environment.IsProduction()
? BachsEnvironment.Production
: BachsEnvironment.Sandbox;
});Then inject BachsClient like any other service:
public sealed class CheckoutController(BachsClient bachs) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(CreateCheckoutSessionRequest request)
{
var result = await bachs.CheckoutSessions.CreateAsync(request);
return result.IsSuccess ? Ok(result.Value) : Problem(result.FailureSummary());
}
}Outside DI (console apps, scripts, Azure Functions, tests), use BachsClient.Create(options) as shown in the Quickstart — it owns its own HttpClient and disposes it with the client.
Every SDK method returns BachsResult<T> (or BachsResult for 204 deletes) instead of throwing for expected, documented failures — a declined payment, a validation error, a 404, a rate limit. This mirrors the Bachs API itself: it uses ordinary HTTP status codes and a machine-readable error_code, not exceptions, and internally we call Refit with its ApiResponse<T> return type specifically so a non-2xx response doesn't throw either.
var result = await client.Products.CreateAsync(request);
if (result.IsSuccess)
{
var product = result.Value!;
}
else if (result.Exception is not null)
{
// Bachs was unreachable - DNS failure, connection refused, TLS error, client-side timeout.
// result.StatusCode and result.Error are null here because no response was ever received.
logger.LogError(result.Exception, "Bachs unreachable");
}
else
{
// A real API response came back with a non-2xx status.
logger.LogWarning("Bachs rejected the request: {Code} {Detail} (request_id={RequestId})",
result.Error?.ErrorCode, result.Error?.Detail, result.RequestId);
if (result.Error?.ErrorCode == BachsErrorCode.ProductArchived)
{
// handle the specific documented error code
}
}BachsErrorCode (in Bachs.Net.Models) has a constant for every code in Bachs's error reference — ProductNotFound, SubscriptionAlreadyCanceled, WithdrawalLimitExceeded, and so on — so you can branch on result.Error.ErrorCode without hardcoding strings.
Prefer exceptions instead? Every result type has an EnsureSuccess() / EnsureSuccessAsync() extension that throws BachsApiException (carrying the same Error/StatusCode/RequestId/IsTransient) if the call failed — useful in scripts, background jobs already inside a try/catch, or tests:
var product = await client.Products.CreateAsync(request).EnsureSuccessAsync();Configuration failures are real exceptions. BachsConfigurationException (missing API key, key/environment mismatch, non-positive timeout) is thrown at startup, not returned as a result — there's no reasonable way to "handle" a misconfigured client at a call site, so it fails fast instead.
Bachs supports an Idempotency-Key header on POST/PATCH requests so a retried request is applied at most once (see Idempotency). Every mutating method on this SDK accepts an optional idempotencyKey parameter:
// Auto-generated (a new GUID) if you don't supply one:
await client.Products.CreateAsync(request);
// Or supply your own, e.g. derived from your own order/record ID, so a retry from your
// own application code (not just the SDK's internal retry policy) also dedupes correctly:
await client.Products.CreateAsync(request, idempotencyKey: $"create-product-{myOrderId}");The key is generated once per logical call, before the SDK's internal retry policy runs — so an automatic retry after a dropped connection reuses the same key and lands on the same idempotent request, rather than each attempt minting a new one.
Money-movement endpoints require an explicit key. Payouts.CreateWithdrawalAsync and Refunds.CreateAsync do not auto-generate a key — you must pass one. These move real money to a third party; an SDK-generated GUID that only exists in memory doesn't protect you if your process crashes after sending the request but before recording the outcome. Derive the key from your own persisted record ID instead, so a restart-and-retry is provably safe:
await client.Refunds.CreateAsync(refundRequest, idempotencyKey: $"refund-{myRefundRecordId}");
await client.Payouts.CreateWithdrawalAsync(withdrawalRequest, idempotencyKey: $"withdrawal-{myWithdrawalRecordId}");Bachs's own API Standards doc explicitly calls out network failures, timeouts, 429, and 5xx as safe to retry — this SDK implements that directly, via Polly, rather than leaving it to every caller to reimplement:
- Retry policy (
EnableRetries, default on): retries network failures, timeouts,408,429, and5xx, up toMaxRetryAttempts(default 3) with jittered exponential backoff. On a429, the server'sRetry-Afterheader always wins over the backoff calculation — Bachs knows its own rate-limit window better than a guess would. - Circuit breaker (
EnableCircuitBreaker, default on): afterCircuitBreakerFailureThreshold(default 5) consecutive failures, stops sending requests forCircuitBreakerDuration(default 30s) so your service fails fast during a real Bachs outage instead of piling up slow, doomed requests. Both are independently toggleable — a low-volume integration rarely triggers the breaker meaningfully, and some callers prefer to own resilience at a higher layer (an existing Polly pipeline, a service mesh).
Because retries happen automatically and transparently, by the time you see a BachsResult the built-in retry budget is already spent. That's exactly what IsTransient tells you:
if (!result.IsSuccess && result.IsTransient)
{
// Retries already happened internally and still didn't succeed - Bachs is down or very
// degraded right now. Surface a "try again shortly" message rather than a hard error,
// and/or queue for a later retry outside this request's lifecycle.
}result.Exception is not null specifically means Bachs was never reached at all (DNS, TCP, TLS, or a client-side timeout) — as distinct from result.StatusCode being populated, which means a response did come back (even a 5xx one).
Every HTTP call is logged (method, path, status, duration, x-request-id — never headers or bodies, since those carry the API key and customer PII) via Microsoft.Extensions.Logging:
| Level | When |
|---|---|
Debug |
Request starting |
Information |
2xx response |
Warning |
4xx response, or a retry being attempted |
Error |
5xx response, or a transport-level exception (Bachs unreachable), or the circuit breaker opening |
Resource clients (ProductsClient, CheckoutSessionsClient, ...) add a thin layer of Debug/Information logs with resource identifiers for higher-level tracing (e.g. "Archiving Bachs product prod_abc123"), never request/response bodies.
This SDK depends only on Microsoft.Extensions.Logging.Abstractions — it does not force Serilog, NLog, or any specific sink on your app. Wire up whichever logging provider your host already uses (Serilog+Seq, in this repo's own convention, works fine — just configure it at the host level as usual).
A product always has a price — fixed, free, or pay-what-you-want:
// Fixed price, single currency
var basic = await client.Products.CreateAsync(new CreateProductRequest
{
Name = "Basic Plan",
Price = new PriceInput { Currency = ProductCurrency.Usd, PriceType = PriceType.Fixed, Amount = "9.00" },
});
// Fixed price with a secondary currency
var pro = await client.Products.CreateAsync(new CreateProductRequest
{
Name = "Pro Plan",
Price = new PriceInput
{
Currency = ProductCurrency.Usd,
PriceType = PriceType.Fixed,
Amount = "29.00",
CurrencyOptions = [new CurrencyOptionInput { Currency = "NGN", Amount = "45000.00" }],
},
});
// Pay-what-you-want, bounded
var donation = await client.Products.CreateAsync(new CreateProductRequest
{
Name = "Support us",
Price = new PriceInput
{
Currency = ProductCurrency.Usd,
PriceType = PriceType.Custom,
MinimumAmount = "1.00",
MaximumAmount = "500.00",
PresetAmount = "15.00",
},
});
// Recurring, with a 14-day free trial (Beta)
var subscriptionProduct = await client.Products.CreateAsync(new CreateProductRequest
{
Name = "Pro Monthly",
Price = new PriceInput { Currency = ProductCurrency.Usd, PriceType = PriceType.Fixed, Amount = "29.00" },
BillingCycle = new SubscriptionCadence { Interval = BillingInterval.Month, Frequency = 1 },
TrialPeriod = new TrialPeriod { Interval = BillingInterval.Day, Frequency = 14 },
});billing_cycle is immutable once set — create a new product for a different cadence rather than trying to change an existing one's interval.
Archiving keeps existing subscriptions billing but removes the product from new checkouts:
await client.Products.ArchiveAsync(productId);
await client.Products.UnarchiveAsync(productId); // idempotent, like archiveAs of this writing, Bachs does not have a coupon/discount API or a dedicated pricing-tier concept — there's no discount, coupon, or promo resource in the API. Model this yourself:
- Tiers (Basic/Pro/Enterprise): create one product per tier, and optionally bundle them with
client.ProductGroupsso a pricing page can render them together (ProductGroups.CreateAsync,.ListAsync, etc. — all products in a group must share one environment). - Discounts/promotions: create a product at the promotional price (or use a
PriceType.Customprice with a suggestedpreset_amount), since there's no separate coupon layer to apply on top of an existing price.
var session = await client.CheckoutSessions.CreateAsync(new CreateCheckoutSessionRequest
{
ProductCart = [new ProductItemRequest { ProductId = "prod_abc123", Quantity = 1 }],
Customer = new NewCustomerRequest { Email = "customer@example.com", Name = "Jane Doe" },
SuccessUrl = "https://yoursite.com/success",
CancelUrl = "https://yoursite.com/cancelled",
ExpiresInMinutes = 60, // 1-1440, defaults to 60
});
var checkoutUrl = session.Value!.CheckoutUrl; // redirect the customer hereRetrieve a session (its Charge is null until the customer pays):
var current = await client.CheckoutSessions.GetAsync(session.Value!.CheckoutId!);Fulfil orders from the webhook, not the redirect. A customer can close the tab/overlay after paying but before your success-page code runs. See Webhooks.
This section is for whoever builds the UI — everything here is plain HTML/JS. The only backend dependency is one endpoint your server exposes that calls CheckoutSessions.CreateAsync (above) and returns its checkoutUrl. The secret key never goes near the browser — that's the whole reason session creation is a server call in the first place; the amount and currency are locked in server-side and can't be tampered with client-side.
There are two ways to present the checkout Bachs builds for you. Both start from the exact same session/checkoutUrl — nothing on the .NET side changes between them, it's purely a frontend choice:
| Hosted redirect | Overlay (in-page modal) | |
|---|---|---|
| UX | Full page navigation to checkout.bachs.io |
Modal on top of your own page; customer never leaves |
| Frontend code needed | None — an <a href> or a 302 redirect |
A <script> tag (or npm install @bachs/js) |
| Best for | Simplest possible integration, email links, non-JS contexts | Keeping the customer on your site/app, better perceived conversion |
Your backend returns checkoutUrl; the frontend just navigates there — a link, a window.location, whatever fits:
<!-- checkoutUrl came from your backend's call to CheckoutSessions.CreateAsync -->
<a href="{{checkoutUrl}}">Pay now</a>When the customer finishes, Bachs redirects them to your SuccessUrl (with ?checkout_id= appended) or CancelUrl. Still confirm the payment via webhook — the redirect can be lost if the customer closes the tab before it fires (see Webhooks).
Load bachs.js and open the session in a modal instead of navigating away:
<button id="pay">Pay with Bachs</button>
<script src="https://checkout.bachs.io/bachs.js"></script>
<script>
// Call this once, when your app loads - not on every click.
Bachs.Initialize({
onEvent: (event) => {
switch (event.type) {
case "checkout.ready":
// the modal finished loading - hide your own spinner here
break;
case "checkout.completed":
// Show a success screen. DO NOT fulfil the order here - see the warning below.
showSuccess(event.data.reference);
break;
case "checkout.failed":
showRetryPrompt();
break;
case "checkout.closed":
// event.data.reason says why (customer closed it, expired, etc.)
resetPayButton();
break;
case "checkout.error":
console.error("Checkout error:", event.data.message);
break;
}
},
});
document.querySelector("#pay").addEventListener("click", async () => {
// Call your own backend endpoint here, which internally calls
// client.CheckoutSessions.CreateAsync(...) and returns { checkoutUrl }.
const { checkoutUrl } = await fetch("/api/create-checkout", { method: "POST" }).then(r => r.json());
Bachs.Checkout.open({ checkoutUrl });
});
</script>React/Next.js, via the npm package instead of the script tag:
"use client";
import { useEffect } from "react";
import { loadBachs } from "@bachs/js";
export function PayButton() {
useEffect(() => {
loadBachs().then((Bachs) => Bachs.Initialize({ onEvent: handleCheckoutEvent }));
}, []);
async function pay() {
const Bachs = await loadBachs();
const { checkoutUrl } = await fetch("/api/create-checkout", { method: "POST" }).then(r => r.json());
Bachs.Checkout.open({ checkoutUrl });
}
return <button onClick={pay}>Pay with Bachs</button>;
}Every checkout.* event. These drive your UI only — never fulfilment:
| Event | Fires when | Use it for |
|---|---|---|
checkout.opened |
The overlay opened | Analytics |
checkout.loaded |
The iframe finished loading | Analytics |
checkout.ready |
Mounted and ready for input | Hide your loading state |
checkout.completed |
Payment succeeded | Show a success screen (not fulfilment) |
checkout.failed |
Payment failed | Show a retry prompt |
checkout.expired |
Session expired before payment | Prompt the customer to restart |
checkout.closed |
Overlay closed (event.data.reason says why) |
Reset your button state |
checkout.error |
An SDK-level error, e.g. a bad token | Log event.data.message |
Never fulfil an order from
checkout.completed. A customer can close the modal after paying but before that event's handler runs — it's a client-side event over an unreliable connection. Thecollection.succeededwebhook (see Webhooks) is the only source of truth for fulfilment, on both the redirect and overlay paths. This is the single most important rule in this whole section.
Environment. There's no separate test/live toggle on bachs.js — a session created with a sk_sandbox_... key is already a sandbox session, and its checkoutUrl points at the matching checkout. Frontend code doesn't change between sandbox and production; only the backend's key does (see Configuration).
Full API reference for bachs.js (Bachs.Initialize, Bachs.Checkout.open/close/isOpen, all options): Add an overlay checkout. There's also a running demo storefront at snapkit.bachs.io showing one-time, subscription, trial, and pay-what-you-want checkouts in both modes against the sandbox.
var customer = await client.Customers.CreateAsync(new CreateCustomerRequest { Email = "ada@example.com", Name = "Ada Lovelace" });
var updated = await client.Customers.UpdateAsync(customer.Value!.CustomerId, new UpdateCustomerRequest { Name = "Ada, Countess of Lovelace" });
var page = await client.Customers.ListAsync(search: "ada@example.com");You don't need to create a customer up front — a checkout session can create one inline from just an email.
Customer portal, so a customer can manage their own billing (update payment method, view invoices, cancel):
var portalSession = await client.CustomerSessions.CreateAsync(customer.Value!.CustomerId);
// portalSession.Value!.Url carries a one-time credential - redirect immediately, never log it,
// and create a fresh session every time (they're short-lived by design)."Recurring product" and "subscription" are the same thing seen from two sides: a recurring product is a Product with BillingCycle set; a subscription is the object created when a customer checks that product out.
There is no create-subscription endpoint, and no manual "recharge the customer" step either. Renewal is fully automatic on Bachs's side: the card is collected once, at the first checkout, and at the end of every billing period Bachs advances the subscription to the next cycle, opens an invoice, and charges that saved card off-session — no customer interaction, no redirect, no code on your end, for the entire life of the subscription. There is no endpoint to "bill the next cycle" because there's nothing for it to do. Your only job each cycle is reacting to the webhooks Bachs fires (invoice.paid on success; see "Failed payments" below for what happens on failure) to keep your own access/records in sync.
Set up the product with a BillingCycle (see Products and pricing) and run a normal checkout:
var subCheckout = await client.CheckoutSessions.CreateAsync(new CreateCheckoutSessionRequest
{
ProductCart = [new ProductItemRequest { ProductId = subscriptionProductId, Quantity = 1 }],
Customer = new NewCustomerRequest { Email = "ada@example.com", Name = "Ada Lovelace" },
SuccessUrl = "https://yoursite.com/success",
CancelUrl = "https://yoursite.com/cancelled",
});
// Subscription checkouts can only be paid by card (SUBSCRIPTION_METHOD_NOT_SUPPORTED otherwise).Once created, the customer.subscription.created webhook (or Subscriptions.GetAsync) gives you the subscription:
var subscription = await client.Subscriptions.GetAsync(subscriptionId);| Status | Meaning |
|---|---|
Trialing |
In a free trial, no charge taken yet — first charge happens when the trial ends |
Active |
Billing normally, card charged at the end of each cycle |
PastDue |
A renewal charge failed; Bachs is retrying automatically (see "Failed payments" below) |
Unpaid |
Recovery exhausted, account set to keep unpaid subscriptions; a later successful payment reactivates it |
Canceled |
Terminal — no later payment can revive it |
Trials (Beta). Set TrialPeriod on the product — the customer isn't charged until the trial ends; their card is still saved at checkout. Manage an existing subscription's trial with TrialEnd on UpdateSubscriptionAsync (send exactly one change intent per call): a future TrialEnd extends the trial — this works even on an Active subscription, moving it back to Trialing — while a TrialEnd of now/the past ends the trial immediately and bills the first cycle right away. Ending a trial only works while the subscription is actually Trialing; a past TrialEnd elsewhere returns 400.
await client.Subscriptions.UpdateAsync(subscriptionId, new UpdateSubscriptionRequest
{
// set exactly one of: a new plan/product, a new trial end, or a new payment method
});Proration. When changing plan, choose how the price difference is handled via ProrationBehavior:
public static class ProrationBehavior
{
public const string InvoiceNow = "invoice_now"; // charge/credit the difference immediately
public const string NextCycle = "next_cycle"; // fold it into the next renewal invoice
public const string None = "none"; // no proration at all
}Upgrades net a charge (settled per ProrationBehavior); downgrades always net a customer credit, regardless of which behavior you pick — Bachs never refunds a downgrade to the card. Credit is a running per-customer/per-currency balance drawn down automatically against future invoices before the card is charged (an invoice fully covered by credit never touches the card). The target product must share the subscription's billing interval and have a price in its currency, or the update returns 400.
Cancel immediately or at period end:
await client.Subscriptions.CancelAsync(subscriptionId, new CancelSubscriptionRequest { CancelAtPeriodEnd = true });Failed payments — Bachs runs dunning automatically, you don't build it. A failed renewal moves the subscription to PastDue and Bachs runs its own automated retry schedule (retry 1 at +1 day, retry 2 at +3 days after that, retry 3 at +5 days after that — three attempts over ~9 days), emailing the customer a hosted "update payment method" link on every attempt (valid 30 days, also reachable from the customer portal). A success at any point fires invoice.paid and moves the subscription back to Active. Your job is only to react to invoice.payment_failed/invoice.paid (see Webhooks) to keep your own access/records in sync — the retry scheduling and recovery email are entirely Bachs's responsibility. What happens if all three retries fail (cancel vs. mark Unpaid) is a per-account dashboard setting, defaulting to cancel.
Bachs's error reference still lists Subscriptions/Trials under "Limited Access" (
SUBSCRIPTIONS_NOT_ENABLED/TRIALS_NOT_ENABLED, contact Bachs support to enable) — but Bachs's own Subscriptions guide pages describe the feature with no gating language, and a real sandbox account can complete subscription checkouts without ever hitting that error. Try it in your own sandbox rather than assuming it's blocked.
Only one refund can be created per charge; refunds settle asynchronously, so track completion via the refund.paid / refund.failed webhooks rather than the initial response:
var refund = await client.Refunds.CreateAsync(
new CreateRefundRequest { ChargeId = chargeId, Reference = $"refund-{myRefundRecordId}", FeeBearer = FeeBearer.Org },
idempotencyKey: $"refund-{myRefundRecordId}"); // required - see Idempotency aboveLimited Access. Typical flow: look up a bank, resolve the account name, save it as a destination, then withdraw.
var banks = await client.Payouts.ListBanksAsync(countryCode: "NG");
var resolved = await client.Payouts.ResolveBankAccountAsync(new BankAccountResolveRequest
{
BankCode = "058",
AccountNumber = "0123456789",
});
// resolved.Value!.Data holds the provider's raw response (JsonElement) - typically an
// account_name field - since this endpoint's response shape isn't fully typed upstream.
// Parse it and show the resolved account name to the user to confirm before saving.
var destination = await client.Payouts.CreateDestinationAsync(new PayoutDestinationRequest
{
DestinationType = PayoutDestinationType.BankAccount,
Currency = "NGN",
AccountNumber = "0123456789",
// ...
});
await client.Payouts.CreateWithdrawalAsync(
new CreateWithdrawalRequest { PayoutDestinationId = destination.Value!.Id, FromCurrency = "USD", ToCurrency = "NGN", Amount = "500.00", PaymentMethod = PayoutDestinationType.BankAccount, Reference = $"withdrawal-{Guid.NewGuid():N}", Email = "finance@yoursite.com" },
idempotencyKey: $"withdrawal-{myWithdrawalRecordId}"); // required - see Idempotency aboveWatch for WithdrawalLimitExceeded / DailyWithdrawalLimitExceeded in result.Error.ErrorCode — both carry a details object (as raw JSON on BachsErrorResponse... consult the error reference) with requested_amount_usd / max_allowed_usd you can surface directly.
Beta. Save evidence over one or more calls, optionally attach documents, then submit once:
await client.Disputes.UpdateEvidenceAsync(disputeId, new DisputeEvidenceUpdateRequest { /* ... */ });
await using var fileStream = File.OpenRead("receipt.pdf");
var upload = await client.Disputes.UploadDocumentAsync(fileStream, "receipt.pdf", "application/pdf");
await client.Disputes.SubmitAsync(disputeId); // one-way - submit only once evidence is completeLimited Access. Quote, then execute against the quote:
var quote = await client.Conversions.CreateQuoteAsync(new ConversionQuoteRequest
{
FromCurrency = "USD", ToCurrency = "NGN", Amount = "100.00",
});
var conversion = await client.Conversions.ExecuteAsync(new ConversionCreateRequest { QuoteId = quote.Value!.QuoteId, FromCurrency = "USD", ToCurrency = "NGN", Amount = "100.00" });Bachs uses two paging styles depending on the endpoint — see each resource client's XML docs for which one applies. BachsPagination turns either into a single IAsyncEnumerable<T>:
// Cursor-based (Products, Product Groups):
await foreach (var product in BachsPagination.EnumerateByCursorAsync(
(cursor, ct) => client.Products.ListAsync(limit: 100, cursor: cursor, cancellationToken: ct),
page => page.Items ?? [],
page => page.Pagination?.NextCursor))
{
Console.WriteLine(product.Name);
}
// Offset-based (Customers, Payments, Refunds, Payouts, Subscriptions, Disputes, Conversions):
await foreach (var customer in BachsPagination.EnumerateByOffsetAsync(
(offset, limit, ct) => client.Customers.ListAsync(limit: limit, offset: offset, cancellationToken: ct),
page => page.Items,
page => page.Pagination.HasMore ?? false))
{
Console.WriteLine(customer.Email);
}A failed page fetch throws BachsApiException (via EnsureSuccess) rather than silently truncating the stream — a caller enumerating "all of X" needs to know the set came back incomplete.
Webhooks are the source of truth for state changes — poll nothing. Set up an endpoint (dashboard or client.Webhooks.CreateEndpointAsync), then verify and parse deliveries in your receiver.
samples/Bachs.Net.WebhookReceiveris a complete, runnable minimal-API receiver using everything in this section — start it withBACHS_WEBHOOK_SECRET=whsec_... dotnet run --project samples/Bachs.Net.WebhookReceiver, point a tunnel (devtunnel, ngrok, ...) at its port, and register the resulting URL as a webhook endpoint to see real deliveries flow through.
Every delivery carries X-Bachs-Timestamp (Unix seconds) and X-Bachs-Signature (HMAC-SHA256 hex digest of "{timestamp}.{raw_body}", keyed by the endpoint's signing secret — returned once at creation, or fetched via GetEndpointSecretAsync).
// ASP.NET Core minimal API
app.MapPost("/webhooks/bachs", async (HttpRequest request, ILogger<Program> logger) =>
{
using var reader = new StreamReader(request.Body);
var rawBody = await reader.ReadToEndAsync(); // read raw, BEFORE any JSON parsing
if (!BachsWebhookVerifier.TryVerifyAndParse(
rawBody,
request.Headers["X-Bachs-Signature"]!,
request.Headers["X-Bachs-Timestamp"]!,
webhookSigningSecret, // from your secret store, not hardcoded
out var webhookEvent,
logger: logger)) // optional - see "Forward compatibility" below
{
return Results.Unauthorized();
}
// webhookEvent!.Id dedupes deliveries - Bachs guarantees at-least-once, so the same event
// can arrive more than once. Track processed IDs (e.g. in your own DB with a unique
// constraint) if your handler isn't naturally idempotent.
switch (webhookEvent.Type)
{
case WebhookEventType.CollectionSucceeded:
{
var payload = webhookEvent.DataAs<CollectionSucceededPayload>();
// fulfil the order using payload!.Reference / payload.ChargeId
break;
}
case WebhookEventType.SubscriptionDeleted:
{
var payload = webhookEvent.DataAs<SubscriptionEventPayload>();
// revoke access for payload!.Customer!.CustomerId
break;
}
}
return Results.Ok();
});Never parse the body before verifying. Re-serializing JSON can change whitespace/byte order and silently break the signature check — BachsWebhookVerifier always takes the raw string.
WebhookEventType (in Bachs.Net.Models) has a constant for all 21 documented event types. Bachs.Net.Webhooks.Payloads has a typed record for each payload shape (several event types share one shape — e.g. refund.created / refund.paid / refund.failed all use RefundEventPayload):
| Event type(s) | Payload type |
|---|---|
collection.succeeded |
CollectionSucceededPayload |
collection.failed |
CollectionFailedPayload |
collection.abandoned |
CollectionAbandonedPayload |
collection.underpaid |
CollectionUnderpaidPayload |
customer.subscription.created/updated/deleted |
SubscriptionEventPayload |
customer.created / customer.updated |
CustomerEventPayload |
dispute.created / dispute.updated |
DisputeEventPayload |
refund.created / refund.paid / refund.failed |
RefundEventPayload |
payout.created / payout.paid / payout.failed |
PayoutEventPayload |
conversion.completed / conversion.failed |
ConversionEventPayload |
invoice.created / invoice.paid / invoice.payment_failed |
InvoiceEventPayload |
webhookEvent.DataAs<T>() deserializes the envelope's raw data into whichever of the above matches webhookEvent.Type.
WebhookEventType and every *Payload record are string-keyed, not C# enums, specifically so a new event type Bachs adds later doesn't throw or fail deserialization in an SDK version that predates it — see Design notes. Concretely:
BachsWebhookVerifier.TryVerifyAndParsestill returnstrueand gives you a fully parsedBachsWebhookEventfor a type it doesn't recognize. Signature verification doesn't care about the type at all.webhookEvent.IsKnownEventTypetells you whetherTypematches one of the values inWebhookEventType.All.- Pass an
ILoggertoTryVerifyAndParse's optionallogger:parameter and it logs aWarningautomatically for an unrecognized type — "Bachs sent an event type this SDK doesn't know about yet" — so you find out from your own logs rather than by a customer noticing a missed event. This is opt-in and off by default (no logger, no log). - You can still act on it:
webhookEvent.Datais rawJsonElementregardless, sowebhookEvent.Data.GetProperty("some_field")works even without a typed payload record for that event type yet. Branch on the raw string (webhookEvent.Type == "some.new.event") until this package ships a matching constant and payload type.
var endpoint = await client.Webhooks.CreateEndpointAsync(new CreateWebhookEndpointRequest
{
Name = "Production events",
Url = "https://api.example.com/webhooks/bachs",
EventTypes = [WebhookEventType.CollectionSucceeded, WebhookEventType.RefundPaid],
});
// endpoint.Value!.SigningSecret is returned ONLY here - store it immediately (secret manager),
// it cannot be retrieved again later, only rotated.
await client.Webhooks.RotateSecretAsync(endpoint.Value!.EndpointId); // update your verifier's secret FIRST, then rotate
await client.Webhooks.ReplayEventAsync(new ReplayWebhookEventRequest { EventId = "evt_..." }); // re-deliver a missed eventA few decisions worth knowing about if you're extending this SDK or diagnosing something unexpected:
- Enum-shaped API fields are
string, not C#enum. Bachs already documents Beta/Limited-Access states and has changed field shapes across its own changelog; a strictenumthrows on deserialization the instant Bachs adds a new value, breaking every consumer on an API-side change they had no control over. Companion static classes (ProductStatus,SubscriptionStatus,WebhookEventType,BachsErrorCode, ...) give you IntelliSense and compile-time-checked constants without that failure mode. For webhook event types specifically,BachsWebhookEvent.IsKnownEventTypeandTryVerifyAndParse's optionallogger:parameter surface an unrecognized type as aWarninglog instead of silence — see Forward compatibility. - Multi-targeting (
net8.0;net9.0;net10.0). The same source compiles once per target framework into separate assemblies, all shipped inside one.nupkg; NuGet's asset selection picks whichever assembly matches the consuming app's target framework automatically. A net10 app gets the net10-compiled assembly (and whatever JIT/GC improvements that runtime has) purely by restoring the package — no code change on either side. An app still on net8 keeps getting the net8 build. This SDK doesn't use any framework-version-specific APIs, so adding a new TFM later is just adding it to<TargetFrameworks>. - Money stays a
string. This matches Bachs's own convention exactly (decimal strings in major units, e.g."29.00", never floats or minor units) — see the API's own "Agent Instructions" banner. Converting todecimalis a one-linedecimal.Parseat your call site if you need arithmetic. - Timestamps become
DateTimeOffset.System.Text.Jsonparses Bachs's ISO 8601 UTC strings natively. - Refit's
ApiResponse<T>all the way through, wrapped inBachsResult<T>— see The result pattern. - Generated vs. hand-written. Everything under
Models/Generated/is produced byscripts/generate_models.pyfrom Bachs's OpenAPI spec — do not hand-edit those files, re-run the generator instead. A handful of endpoints (checkout settings, webhook replay, dispute submit) use inline/un-named schemas in the spec and are hand-written underModels/Extra/instead.
This project is open source (MIT-licensed — see LICENSE). Contributions are welcome; see CONTRIBUTING.md for the project layout, how to regenerate models when Bachs's API changes, what needs a test, and the release process. Please also read CODE_OF_CONDUCT.md, and see SECURITY.md before filing a security issue in public.
dotnet build Bachs.Net.slnx
dotnet test tests/Bachs.Net.Tests/Bachs.Net.Tests.csproj
dotnet pack src/Bachs.Net/Bachs.Net.csproj -c Release -o artifactsartifacts/ will contain Bachs.Net.<version>.nupkg (the package) and .snupkg (the debug symbols package). A dotnet build/dotnet pack also runs automatically in CI on every push and PR — see .github/workflows/ci.yml. For how releases actually get published to NuGet.org, see "Releasing" in CONTRIBUTING.md.