@solopress/payments-client
v1.2.0
Published
Typed client for the Solopress Payments API
Readme
@solopress/payments-client
Typed client for the Solopress Payments API.
npm install @solopress/payments-clientThe shape of an integration
Three server-side calls and one browser handoff:
- Server creates a payment intent. The amount is fixed here and can never be changed.
- Server creates a client session and passes the token to the browser. The browser never sees an API key.
- Browser tokenises the card or approves a PayPal order, then confirms.
- Server receives a
payment.succeededwebhook and fulfils the order.
Step 4 is the one that matters. The browser returning to your success page is a hint; the webhook is the fact.
Server: create an intent
import { PaymentsClient } from "@solopress/payments-client";
const payments = new PaymentsClient({
baseUrl: process.env.PAYMENTS_API_URL!,
apiKey: process.env.PAYMENTS_API_KEY!, // spapi_live_… or spapi_test_…
});
// Amounts are integer minor units: 4956 is £49.56.
const intent = await payments.createPaymentIntent({
merchant_reference: quote.publicId,
amount: quote.totalPence,
currency: "GBP",
description: `Quote ${quote.reference}`,
allowed_providers: ["opayo", "paypal"],
customer: { first_name: quote.firstName, last_name: quote.lastName, email: quote.email },
billing_address: { line1: quote.address1, city: quote.city, postal_code: quote.postcode, country: "GB" },
return_url: `https://example.com/quotes/${quote.publicId}/result`,
cancel_url: `https://example.com/quotes/${quote.publicId}`,
metadata: { quote_id: quote.id },
});
const session = await payments.createClientSession(intent.id);
// Pass session (including session.token) to the browser. Never the API key.Idempotency
Every mutating call sends an Idempotency-Key. One is generated automatically, which is right for a user-initiated action.
Supply your own when a retry must replay the earlier result — derive it from the thing being paid for rather than from the attempt:
await payments.createPaymentIntent(input, { idempotencyKey: `quote-${quote.id}-v1` });Browser
Use @solopress/payments-react, which handles the whole next_action union. If you are not using React, the union is four cases and is documented in openapi/openapi.yaml.
Errors
import { PaymentsError } from "@solopress/payments-client";
try {
await payments.confirmPaymentIntent(intent.id, { provider: "opayo", payment_token: token });
} catch (error) {
if (error instanceof PaymentsError) {
if (error.declined) {
// The bank said no. A business outcome: show it to the customer.
return show(error.message);
}
if (error.indeterminate) {
// The provider could not be reached, so the payment's state is unknown.
// Do NOT tell the customer it failed and do NOT retry automatically:
// reconciliation will settle it within a few minutes.
return show("We are confirming your payment. Please do not try again.");
}
console.error(error.code, error.message, "request", error.requestId);
}
throw error;
}error.requestId is the thing to quote when reporting a problem: it appears on the matching row in the admin TUI's request log.
Webhooks
Verify over the raw body. Parsing and re-serialising changes the bytes and the signature will not match.
// app/api/webhooks/payments/route.ts
import { verifyWebhook, SIGNATURE_HEADER } from "@solopress/payments-client/webhooks";
export async function POST(request: Request) {
const rawBody = await request.text(); // not request.json()
let event;
try {
event = await verifyWebhook(rawBody, request.headers.get(SIGNATURE_HEADER), process.env.PAYMENTS_WEBHOOK_SECRET!);
} catch {
return new Response("invalid signature", { status: 400 });
}
if (event.type === "payment.succeeded") {
// Must be idempotent: a webhook can be delivered more than once, and the
// reconciliation sweep can produce the same event again.
await fulfilOrder(event.payment_intent_id!, event.id);
}
return new Response("ok"); // 2xx quickly; do slow work after, or in a queue
}Two rules worth stating plainly:
- Handle redelivery. Key your fulfilment on
event.idand ignore one you have already processed. - Respond quickly. A non-2xx or a timeout schedules a retry with exponential backoff, over roughly a day and a half.
The signing secret is shown once, when the endpoint is created in the admin TUI.
API
| Method | Returns |
|---|---|
| createPaymentIntent(input, opts?) | PaymentIntent |
| getPaymentIntent(id, opts?) | PaymentIntent |
| listPaymentIntents(params?, opts?) | ListResponse<PaymentIntent> |
| confirmPaymentIntent(id, input, opts?) | PaymentIntent |
| cancelPaymentIntent(id, opts?) | PaymentIntent |
| createRefund(intentId, input?, opts?) | Refund |
| listRefunds(intentId, opts?) | ListResponse<Refund> |
| listPaymentIntentEvents(id, opts?) | ListResponse<PaymentEvent> |
| createClientSession(intentId, opts?) | ClientSession |
| listProviders(opts?) | ListResponse<ProviderSummary> |
