Skip to main content

Crate cratefield_adapter_polar

Crate cratefield_adapter_polar 

Source
Expand description

cratefield-adapter-polar: the Payments port over the Polar API, a Merchant of Record (issue #690). Polar is the legal seller: it collects the payment, handles VAT, GST and sales tax, and pays the founder out, so a venture with no company yet can sell worldwide. Switching to cratefield-adapter-stripe later is a change of composition, not of module code (ADR 0027).

It uses the runtime’s HttpClient and Clock ports, no vendor SDK and no reqwest, so the same adapter runs on Workers and natively.

What maps and what does not.

PaymentsPolar
create_subscription_checkoutPOST /v1/checkouts/ for one product (monthly or annual)
create_checkoutPOST /v1/checkouts/ with an ad-hoc price on the configured one-off product
create_portal_sessionPOST /v1/customer-sessions/ → customer_portal_url
refundPOST /v1/refunds/ (full or partial, with a reason), idempotent by metadata
report_usagePOST /v1/events/ingest, deduplicated on external_id
get_dispute / list_disputes / close_dispute/v1/disputes/
verify_webhook / verify_webhook_requestStandard Webhooks, both of Polar’s key derivations
create_connect_account_link, charge_with_transferPaymentsError::Unsupported

Card data never crosses this adapter: every call names a Polar object by id or returns a hosted Polar URL.

Secrets. The organization access token and the webhook secret come in through Polar::new (read them from the Workers Env or the secrets layer), Polar::from_config or Polar::from_env. Neither is ever logged, formatted by Debug, or put in an error.

cratefield-adapter-polar — Merchant of Record.

cratefield-adapter-polar on crates.io cratefield-adapter-polar documentation MIT

§cratefield-adapter-polar

The Payments port over Polar, a Merchant of Record, for the Cratefield harness (issue #690, ADR 0027).

Polar is the legal seller. It collects the payment, handles VAT, GST and sales tax worldwide, and pays the founder out, so a venture run by an individual with no company can sell from day one. When a company exists, switching to cratefield-adapter-stripe is a change of composition: the venture’s code talks to Payments, never to Polar.

It talks to Polar through the runtime’s HttpClient port, so the one adapter runs unchanged on Cloudflare Workers and on the native runtime. No vendor SDK, no reqwest.

§What it does

PaymentsPolar
create_subscription_checkoutPOST /v1/checkouts/ for one product. A Polar product has one interval, so monthly and annual are two products, and price_ref is the product id
create_checkout (one-off)POST /v1/checkouts/ with an ad-hoc fixed price on the product set with with_one_off_product
create_portal_sessionPOST /v1/customer-sessions/ → customer_portal_url (plan changes, invoices, cancelling)
refundPOST /v1/refunds/, full (the order’s refundable_amount) or partial, with a reason
report_usagePOST /v1/events/ingest, deduplicated on external_id = UsageReport::identifier
get_dispute, list_disputes, close_disputeGET /v1/disputes/{id}, GET /v1/disputes/, POST /v1/disputes/{id}/accept
verify_webhook_request, verify_webhookStandard Webhooks headers, with both of Polar’s key derivations
create_connect_account_link, charge_with_transferPaymentsError::Unsupported. Polar pays the seller out itself and has no server-side charge API

Checkout and subscription metadata (the venture’s account id, say) goes to Polar’s metadata, which Polar copies onto the resulting order and subscription, so it comes back on their webhooks. The customer’s email goes to customer_email.

Customer ids. By default a customer_ref is Polar’s customer id. With .with_customer_ids(CustomerIds::External) it is the venture’s own id (Polar’s external_customer_id) for checkout, the portal, usage events and disputes, so the venture never stores a Polar id at all.

Refund amounts are net. Polar refunds the amount before tax and refunds the matching tax itself. Polar takes no Idempotency-Key, so the adapter stores the request’s key in the refund’s metadata (cratefield_idempotency_key) and looks it up before creating: a retried refund returns the one already made. refund records the reason set with with_refund_reason (default customer_request); refund_with_reason names it per call.

Usage is ingested as one event per report: name is the meter event name, the value goes under the metadata key value (or with_usage_value_key), and external_id is the report’s identifier. Re-sending an identifier Polar has already ingested comes back as already_reported: true, so the hourly UsageReport::hourly flush in docs/PAYMENTS.md works unchanged. report_usage_batch sends many reports per request (up to MAX_INGEST_BATCH).

§Webhooks

Polar signs with Standard Webhooks: headers webhook-id, webhook-timestamp and webhook-signature (v1,<base64>, space-separated), HMAC-SHA256 over {id}.{timestamp}.{raw body}. The key depends on the secret’s age, and the adapter tries both, as Polar’s own SDKs do:

  • secrets created on or after 2026-09-08: the Standard Webhooks key, the base64 after whsec_;
  • older secrets (“Polar HMAC”): the UTF-8 bytes of the whole whsec_… string.

A timestamp more than five minutes from now, either way, is refused. Use verify_webhook_request(&headers, body): it takes the headers directly. The port’s older verify_webhook(header, body) takes the three headers packed by signature_header(&headers).

The returned event’s id is the webhook-id, which Polar keeps across retries: claim it through cratefield_core::Inbox and a replayed delivery is applied once. normalize(&event) maps it onto a typed PolarEvent:

Polar eventPolarEvent
checkout.created / .updated / .expiredCheckout (Created / Updated / Expired)
subscription.created / .active / .updated / .canceled / .uncanceled / .revoked / .past_due / .paused / .resumed / .cycled / .migratedSubscription with the matching SubscriptionChange
order.created / .paid / .updated / .refundedOrder (Created / Paid / Updated / Refunded)
refund.created / .updatedRefund, plus Dispute when the refund prevented one
customer.created / .updated / .deleted / .state_changedCustomer
benefit*, product.*, member.*, customer_seat.*, discount.*, organization.*nothing (logged at debug)
anything elsenothing (logged), never an error

§Disputes and chargebacks

Polar sends no dispute webhook. It exposes disputes through its API, and records a balance.dispute (and, when won, balance.dispute_reversal) system event. So a venture polls: a scheduled job calls list_disputes(&DisputeListRequest { open_only: true, .. }) and get_dispute for the disputes it is tracking, and claims each dispute.event_key() (dispute:{id}:{status}) through the Inbox, so each transition is acted on once:

Polar statusDisputeStatusphase()What a venture typically does
early_warningEarlyWarningOpenflag the account
needs_responseNeedsResponseOpenflag; evidence goes in through the Polar dashboard
under_reviewUnderReviewOpenkeep flagged
wonWonWonrestore
lostLostLostrevoke what the payment funded
preventedPreventedClosedPolar refunded to head it off: nothing to restore

A dispute costs $15 whatever the outcome, deducted from the Polar balance. To head off chargebacks, Polar may refund an order within 60 days at its own discretion. When it does, the order is refunded, any related subscription is cancelled and benefits are revoked. That refund arrives as refund.created with reason dispute_prevention, and normalize yields both the Refund and a Dispute in Prevented. Polar holds accounts to a 0.4% chargeback rate. close_dispute accepts the chargeback (settles it as lost).

§Usage

ⓘ
use std::sync::Arc;
use cratefield_adapter_polar::{CustomerIds, Environment, Polar};

let http = Arc::new(cratefield_runtime_cloudflare::FetchClient);
let clock = Arc::new(cratefield_runtime_cloudflare::WorkersClock);

let payments: Arc<dyn cratefield_core::Payments> = Arc::new(
    Polar::new(
        http,
        clock,
        Environment::parse(env.var("POLAR_ENVIRONMENT").ok().map(|v| v.to_string()).as_deref()),
        env.secret("POLAR_ACCESS_TOKEN").map(|s| s.to_string()).unwrap_or_default(),
        env.secret("POLAR_WEBHOOK_SECRET").map(|s| s.to_string()).unwrap_or_default(),
    )
    .with_customer_ids(CustomerIds::External),
);

let runtime = cratefield_runtime_cloudflare::Cloudflare::new().payments_arc(payments);

Natively, Polar::from_env(http, clock) reads the same three variables, and Polar::from_config(http, clock, &config) reads them from the harness config. POLAR_ENVIRONMENT is sandbox (https://sandbox-api.polar.sh, the default, so a missing setting cannot move real money) or production (https://api.polar.sh). A blank token makes the adapter NotConfigured with no network call. The token and the webhook secret are never logged, printed by Debug, or put in an error.

§Verification

Request shaping for every call, error mapping, webhook verification (both key derivations, a wrong secret, a stale and a future timestamp, a tampered body and id), the event mapping for every webhook type, both dispute lifecycles, refund and usage idempotency, Unsupported, and switching from Stripe by composition are tested against a scripted HttpClient. The response fixtures follow Polar’s OpenAPI schemas (API 2026-04) field for field. The live path against the Polar sandbox is needs-human: it needs an organization token, which does not live in the repo.


MIT. Built in the open for Cratefield, a Factory Zero venture.

Structs§

CheckoutEvent
A checkout session’s state. status is Polar’s (open, expired, confirmed, succeeded, failed).
CustomerEvent
A customer, with the venture’s own id when it was set.
OrderEvent
An order (every paid transaction on Polar is one). status is Polar’s (pending, paid, refunded, partially_refunded, …); billing_reason says why it exists (purchase, subscription_create, subscription_cycle, …).
Polar
Payments over the Polar API.
RefundEvent
A refund. payment_ref is the order id, what Payments::refund takes; status is Polar’s (pending, succeeded, failed, canceled), and reason includes Polar’s own dispute_prevention.
SubscriptionEvent
A subscription’s state after the change. status is Polar’s (incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid, paused).
UsageBatchReported
The outcome of Polar::report_usage_batch: Polar answers counts, not per-event results.

Enums§

CheckoutChange
checkout.created / checkout.updated / checkout.expired.
CustomerChange
Which customer.* event it was.
CustomerIds
What a customer_ref names, everywhere the port takes or returns one.
Environment
Which Polar environment the adapter talks to. The two are fully isolated: a sandbox token does not work in production, and vice versa.
OrderChange
Which order.* event it was.
PolarEvent
One billing-relevant fact from a verified Polar webhook.
RefundChange
refund.created / refund.updated.
RefundReason
The reason Polar records on a refund (RefundCreate.reason).
SubscriptionChange
Which subscription.* event it was.

Constants§

MAX_INGEST_BATCH
How many usage events go in one POST /v1/events/ingest. Polar documents no batch ceiling; this is our own bound on one request’s size.
PRODUCTION_BASE_URL
Polar’s production API host.
REFUND_IDEMPOTENCY_KEY
The metadata key a refund’s idempotency key is stored under, so a retried Payments::refund finds the refund it already made instead of making a second one (Polar’s API takes no Idempotency-Key).
SANDBOX_BASE_URL
Polar’s sandbox API host: isolated data, users, tokens and organizations.
WEBHOOK_TOLERANCE_SECS
The webhook replay tolerance in seconds, either way: the Standard Webhooks recommendation and the harness default.

Functions§

normalize
Maps one verified event onto zero or more PolarEvents. A refund that prevented a dispute yields both the refund and the dispute. customers decides what a dispute’s customer_ref names, as on the adapter.
signature_header
Packs a delivery’s webhook-id, webhook-timestamp and webhook-signature into the one string Payments::verify_webhook takes, one name: value per line (header values cannot hold a newline, so the packing is unambiguous). None when one is missing.