The Stripe API

Calling Stripe directly, errors and retries, idempotency, and the pinned version

# The Stripe API

The plugin's functions cover Checkout, the portal and subscriptions. For
everything else Stripe has, `Stripe` is the client the plugin itself
uses, with the same secret, the same pinned version and the same errors.

## Calling Stripe

```pascal
uses Askr.Plugin.Stripe.Client, Askr.Plugin.Stripe.Billing;

var
  P: TStripeParams;
  Json: string;
begin
  { The user's last ten invoices. }
  Json := Stripe.Get('/v1/invoices?limit=10&customer=' +
    StripeCustomerId(UserId));

  { A one-off amount on their next invoice. }

  P := Default(TStripeParams);
  P.Add('amount', 5000);
  P.Add('currency', 'nok');
  P.Add('customer', StripeCustomerId(UserId));
  P.Add('description', 'Setup fee');
  Json := Stripe.Post('/v1/invoiceitems', P);
end;
```

| | |
|---|---|
| `Stripe.Post(Path, Params, IdempotencyKey)` | A POST with a form body; the key is optional |
| `Stripe.Get(Path)` | A GET; put a query in the path |
| `Stripe.Delete(Path)` | A DELETE |

Each returns Stripe's reply as JSON text, or raises an `EStripeError`.
`StripeField(Json, 'id')` reads one top-level string field; `JsonParse` in
`Askr.Core.Json` reads the rest.

## Parameters

Stripe takes form fields, not JSON. `TStripeParams` keeps them in the
order they were added and encodes both sides:

```pascal
P := Default(TStripeParams);
P.Add('line_items[0][price]', 'price_1');      { nested, as Stripe writes it }
P.Add('line_items[0][quantity]', 2);           { an Int64 }
P.AddBool('allow_promotion_codes', True);      { true or false }
P.Add('metadata[order_id]', '1042');
```

It is a record with no heap object inside it that needs freeing; start it
with `Default(TStripeParams)`.

## Errors

`EStripeError` is Stripe's error, as Stripe wrote it:

| | |
|---|---|
| `E.Status` | The HTTP status; 0 when the request never reached Stripe |
| `E.Type_` | Stripe's type: `card_error`, `invalid_request_error`, `api_error`, `idempotency_error` — or `config` and `network`, which are the plugin's |
| `E.Code` | Stripe's code: `card_declined`, `resource_missing`, ... |
| `E.DeclineCode` | For a card: `insufficient_funds`, ... |
| `E.Param` | The parameter Stripe objected to |
| `E.RequestId` | Stripe's request id — what its dashboard finds the request by |
| `E.Retryable` | Whether sending it again can help |
| `E.Message` | Stripe's message, with the type and code after it |

**`Retryable` is the one a queue job needs.** It follows Stripe's own
`Stripe-Should-Retry` header when Stripe sends one, as Stripe's libraries
do. Without it: a 409 (a concurrent request with the same key), a 429 and
any 5xx are worth another try, and a network failure is too; a declined
card or a bad parameter never becomes more correct by being sent again.

```pascal
try
  Stripe.Post('/v1/invoiceitems', P, 'setup-fee-' + OrderId);
except
  on E: EStripeError do
    if E.Retryable then
      raise              { the queue tries again }
    else
      RecordFailure(OrderId, E.Message);
end;
```

**The secret is never in an error, a log line or `Describe`.** Neither is
the body of a reply that is not Stripe's JSON — a proxy's error page —
which comes through as its status alone.

## Idempotency

Every POST carries an `Idempotency-Key`. Without one of yours it is a
random key, which is what Stripe's own libraries send, and it makes a
request sent twice by accident — a network hiccup — count once.

**Only your own key covers a retry.** A queue job that fails after Stripe
took its request runs again, and a new random key is a new request. Give
it a key that is the same every time the job runs, and different for
every job:

```pascal
Stripe.Post('/v1/invoiceitems', P, 'setup-fee-' + OrderId);
```

Stripe keeps a key for 24 hours, across everything that shares the
account, and answers the same request with the same key with its first
reply. The same key with a different body is refused. So a key is made
of what identifies the *attempt* — an order, a job — and never of
something another app on the same account also has, like a user id.

## The API version

Every request carries `Stripe-Version: 2026-08-26.dahlia`,
`StripeApiVersion` in `Askr.Plugin.Stripe.Client`. Without it, the
account's default version would decide the shape of every reply, and that
default moves when someone clicks a button in the dashboard.

A webhook's payload follows the endpoint's version instead, which is why
the endpoint has to be created with the same one. Where Stripe has moved
a field between versions and the plugin reads it — the period end moved
from the subscription to its items in 2025-03-31, an invoice's
subscription under `parent` — it reads both places.

## Reference

| Unit | |
|---|---|
| `Askr.Plugin.Stripe` | The plugin, the webhook route, `RequireSubscribed`, `SetStripeWebhookSecret` |
| `Askr.Plugin.Stripe.Billing` | Customers, Checkout, the portal, subscriptions, the events |
| `Askr.Plugin.Stripe.Client` | `TStripeClient`, `TStripeParams`, `EStripeError`, `StripeField`, the HTTP layer and its fake |
| `Askr.Plugin.Stripe.Signature` | `VerifyStripeSignature`, `StripeSignatureHeader` |