Contents
Developers Guide
TypeScript SDK
Typed responses, bounded retries with jitter, and webhook verification over the same contract the REST API publishes. One package, no dependencies. Every example that has a REST equivalent shows it beside the TypeScript. Writing PHP or Python? Use the PHP or Python SDK. Java, C# or Go? The quickstart has every step and the webhook check in your language, each one run against the API on every change.
Install
npm i @payshen/sdk. Zero dependencies. Ships ESM, CommonJS and type declarations.
npm i @payshen/sdkimport { Payshen } from "@payshen/sdk";It targets ES2022 and uses only fetch, so it runs on Node 18+, Bun, Deno and edge runtimes without a bundler plugin. The REST API is frozen at v1 and the package follows semver against it. While the SDK is on 0.x a minor version may still change the client's own shape. The HTTP contract underneath it does not change.
Configuration
The key decides the environment. Nothing else needs setting; the options exist for the cases where a default is wrong.
const client = new Payshen("ps_live_...", {
// Per attempt, not per call: each retry gets a fresh 20s by default.
timeoutMs: 20_000,
// Transient failures only, under the same Idempotency-Key. A 4xx other
// than 429, and an answer the API says not to retry, never are.
maxRetries: 3,
// Point at a mock server in tests. Must be https unless it is localhost.
baseUrl: "https://www.payshen.com",
// Bring your own fetch, for instrumentation or a proxy agent.
fetch: myInstrumentedFetch,
});Server only
Never construct a client in the browser. The key is a bearer credential with full access to your workspace, and anything shipped to a browser is public, whatever the bundler does to it.
Your first payment
A ps_test_ key only ever touches sandbox data, so this is safe to run as it is.
const client = new Payshen("ps_test_...");
const payment = await client.payments.create(
{ amount: 5000, currency: "EUR", country: "DE", reference: "order-1024" },
{ idempotencyKey: "order-1024" },
);5000 is EUR 50.00 here, because amounts are in minor units (see Amounts and currencies). The idempotency key means a retry after a timeout returns the first answer instead of charging twice.
An answer with simulated: true was decided by a simulation: no provider was asked and no money moved. Never ship an order against a simulated payment. A live payment is never answered by a simulation.
Hosted flow
Send a return_url and the customer completes payment on a hosted page, so card data never reaches your servers or ours. The response carries a redirect; the outcome arrives by webhook, or when you read the payment after the customer comes back.
const payment = await client.payments.create({
amount: 5000, currency: "EUR", country: "DE",
return_url: "https://yourshop.com/checkout/return",
});
if (payment.next_action?.type === "REDIRECT") {
redirect(payment.next_action.url);
}Embedded checkout
Keep the customer on your own checkout and let them pay in place. You create the session on your server with ui: "embedded", and the browser package @payshen/checkout mounts the routed provider's own payment fields. The card is entered into the provider's fields and turned into a token there, so it never reaches your servers or ours. Routing stays on our side: the response tells the SDK which fields to load.
@payshen/checkout 0.4.0 and later send the customer only to https pages (http on localhost aside), and a baseUrl that is not https, localhost or a path on your own site throws invalid_base_url when the checkout is created.
Step one, on your server. The response carries a client_secret and an opaque collector; send both to the browser and do not read the collector yourself.
// your backend, e.g. POST /checkout/session
const session = await client.payments.create({
amount: 5000, currency: "EUR", country: "DE", method: "card",
ui: "embedded", return_url: "https://yourshop.com/checkout/return",
});
// return { id, client_secret, status, collector } to the browserStep two, in the browser. Install npm i @payshen/checkout (zero dependencies), then mount and confirm:
import { createCheckout } from "@payshen/checkout";
const checkout = createCheckout({
session: () => fetch("/checkout/session", { method: "POST" }).then((r) => r.json()),
baseUrl: "https://www.payshen.com",
});
checkout.on((e) => {
if (e.type === "ready") payButton.disabled = false;
if (e.type === "completed") show(e.result.status); // CAPTURED, DECLINED, ...
if (e.type === "error") show(e.message);
});
await checkout.mount("#payment-element"); // loads the provider's fields
payButton.onclick = () => checkout.confirm(); // tokenize, then confirm
checkout.unmount(); // on teardown; drops provider objectsThe status confirm() returns is authoritative. If the network drops after it, call confirm() again: the SDK re-sends the same token and we return the settled state rather than charging twice. It never silently retries and never moves the payment to another provider.
When the card issuer asks the customer to authenticate (3-D Secure), the confirm answers REQUIRES_ACTION: nothing is charged yet and it is not a decline. confirm() runs the provider's own challenge in your page, then asks us for the outcome, which we read from the provider, and emits completed. If the customer closes the challenge, confirm() resolves REQUIRES_ACTION; calling it again reopens the challenge and never charges twice. checkout.complete() asks for the outcome without charging or opening anything, and works on the page the customer comes back to. A challenge nobody finishes is cancelled after an hour. Your server learns the outcome from the payment.* webhook; the server SDK has no confirm of its own, because these calls take the session's client_secret, never your API key.
Redirect fallback
When the routed provider has no in-page fields, the session comes back with a redirect instead of a collector, and confirm() sends the customer to the provider's page. Read the outcome the same way as the hosted flow. You write the integration once and it works either way.
Capture, void, refund
Create with capture_method: "manual" to authorize now and take the money later. Amounts are optional: leave one out and the whole amount is used.
await client.payments.capture(id, { amount: 2500 }); // partial
await client.payments.void(id); // release an authorization
await client.payments.refund(id, { amount: 1000 }); // partial refundA refund is clamped on the server to what is actually refundable, and two simultaneous full refunds cannot both succeed. A double click, or a retry you were not sure about, cannot pay a customer back twice.
A refund counts in refunded_amount only once the provider confirms it. Until then it is in refund_pending_amount, and the refund call answers 409 refund_pending: the same idempotency key returns its final result once it is settled. A 502 psp_error is a refusal, nothing was refunded, and a new refund under a new key is safe. A 502 refund_result_unknown means the provider may have refunded: do not retry it under any key; Payshen settles it from the provider's record and you receive payment.refunded if it went through, or payment.refund_failed if it did not.
Two refusals mean nothing was sent and nothing changed: 409 simulated_payment, a live payment a simulation answered, which no provider holds; and 503 provider_unavailable, the payment's provider cannot be reached from this deployment. After the second, a new attempt is safe once the connection is fixed.
UNKNOWN and PENDING
Treat both as in flight. Never as a decline, and never retry the capture or void yourself. UNKNOWN means the provider may have done it and did not manage to tell us; a second capture for the same order is exactly the mistake it exists to prevent. Read the payment again later instead.
Amounts and currencies
Every amount is an integer in the currency's minor unit, and the scale follows the currency, not always hundredths.
{ amount: 5000, currency: "EUR" } // EUR 50.00 two decimals
{ amount: 5000, currency: "JPY" } // JPY 5,000 zero decimals
{ amount: 5000, currency: "KWD" } // KWD 5.000 three decimalsMinor units
This is the most common integration bug in payments, and it is silent: the request succeeds, for the wrong amount. If you format amounts yourself, take the exponent from Intl rather than dividing by 100.
const digits = new Intl.NumberFormat("en", { style: "currency", currency })
.resolvedOptions().minimumFractionDigits;
const major = minor / 10 ** digits;
const display = new Intl.NumberFormat("en", { style: "currency", currency }).format(major);The product never sums totals across currencies, and your code should not either. Without a rate, a mixed total is a number that corresponds to nothing.
Idempotency
Every POST the client sends carries an Idempotency-Key. If you do not supply one, the client generates it and reuses it across every retry of that call, so a connection dropped after the server created a payment cannot become a second charge.
// Supply your own when two separate calls are the SAME business operation:
// a customer double-clicking checkout, or your own job runner retrying.
await client.payments.create(input, { idempotencyKey: `order-${orderId}` });
// Leave it out and the client still protects the retries it makes itself.
await client.payments.create(input);Idempotency
The client's own key protects one call. Only a key you choose, named after the order, protects against your code calling twice.
Sending a key again with the same body returns the original response with an Idempotent-Replay: true header. Sending it with a different body is refused with 422 idempotency_key_reuse rather than doing something you did not ask for. Keys are scoped per workspace and per environment, so the same key in sandbox and in production are independent.
Reading payments and events
Lists are cursor paginated, newest first.
const page = await client.payments.list({ limit: 50, status: "CAPTURED" });
const next = await client.payments.list({ before: page.nextCursor ?? undefined });
const timeline = await client.payments.events(id);
const feed = await client.events.list({ limit: 100, type: "payment.captured" });
for await (const p of client.payments.iter({ status: "CAPTURED" })) handle(p);Each page carries nextCursor, null on the last page, and hasMore; iter() walks every page for you. The events feed is the polling equivalent of a webhook endpoint, for when you would rather not host one.
White-label partners
Only for workspaces flagged as partners. Everything else answers 403 not_a_partner.
const merchants = await client.merchants.list();
// Provisioning requires a LIVE key: a test key answers 403 live_key_required.
await client.merchants.create({
name: "Acme Ltd",
owner_email: "owner@acme.com",
plan: "core",
});Inviting an owner who already has a pending invitation answers 409 already_invited instead of creating a second workspace, so retrying after a timeout is safe.
Verifying webhooks
Verify against the raw body, before you parse it. A body that has been through JSON.parse and back is a different string, and will not verify.
import { verifyWebhookSignature, PayshenAuthError } from "@payshen/sdk";
// The RAW body. A body that has been through JSON.parse and back is a
// different string and will never verify.
const raw = await req.text();
try {
await verifyWebhookSignature({
secret,
header: req.headers.get("x-payshen-signature"),
body: raw,
});
} catch (err) {
if (err instanceof PayshenAuthError) return new Response("bad signature", { status: 400 });
throw err;
}
const event = JSON.parse(raw);The comparison is constant-time and the timestamp is checked, so a delivery captured today goes stale within minutes. Inside that window the same delivery still verifies, and that is not a defect: a signature says who sent a payload, not how many times you have seen it.
Deduplicate on event.id
Delivery is at least once. Duplicates are ordinary: we send an event again when your endpoint times out or answers anything but 2xx, 400, 410 or 422, so a slow endpoint receives the same event twice in normal operation. Every event carries a stable id: record it and skip what you have already processed. claimWebhookEvent verifies, parses and claims in one call, and returns null for a repeat.
An event that does not get through is retried with growing gaps for about 28 hours, then given up. Keep processed event ids for longer than that (a few days is safe), or a late retry of an old event looks new. Events are not ordered: a retried payment.authorized can arrive after payment.captured. Decide by the event's created time and the status it carries, or read the payment, never by arrival order. A 410 tells us you do not want that event; 400 and 422 say it cannot be processed. Those three are not retried.
import { claimWebhookEvent } from "@payshen/sdk";
export async function POST(req: Request) {
const raw = await req.text(); // the raw body, never a parsed object
const event = await claimWebhookEvent({
secret: process.env.PAYSHEN_WEBHOOK_SECRET!,
header: req.headers.get("x-payshen-signature"),
body: raw,
store, // yours: one atomic claim(eventId) method
});
if (!event) return new Response(null, { status: 204 }); // already handled
await fulfil(event);
return new Response(null, { status: 204 });
}The store has one method, claim, and it must be atomic. A read followed by a write lets two concurrent deliveries of the same event both through; the database you already have does it in one statement.
const store = {
async claim(eventId: string) {
const { rowCount } = await db.query(
`INSERT INTO processed_webhook_events (id) VALUES ($1)
ON CONFLICT (id) DO NOTHING`,
[eventId],
);
return rowCount === 1; // 1: this delivery claimed it. 0: already had it.
},
};memoryWebhookEventStore() is for tests and local development only: it forgets on restart and is not shared between instances. And because the claim happens before your work runs, a process that dies in between leaves an event marked handled that never was. Where that matters, anything that moves money, use parseWebhookEvent and do the claim and the work in one transaction of your own.
Errors
Every failure is a typed error carrying the HTTP status and a stable code. Switch on the code; the message is for people and may change. The full list is in the reference, and in the PayshenErrorCode type, so your editor completes them. The code is always the server's, including on a PayshenValidationError (idempotency_key_reuse, invalid_amount and the like).
One code is the client's own: unexpected_redirect. The API never answers with a redirect, so the client does not follow one: it means baseUrl is not the Payshen API. baseUrl must be https (http for localhost only); anything else is refused when the client is made (0.10.0).
import { PayshenError, PayshenRateLimitError, PayshenValidationError } from "@payshen/sdk";
try {
await client.payments.create(input);
} catch (err) {
if (err instanceof PayshenRateLimitError) {
await sleep((err.retryAfterSeconds ?? 1) * 1000);
} else if (err instanceof PayshenValidationError) {
return badRequest(err.message);
} else if (err instanceof PayshenError) {
log({ status: err.status, code: err.code });
}
}Retries and timeouts
The client retries network failures, timeouts and the statuses 408, 425, 429, 500, 502, 503 and 504 with exponential backoff and jitter, always under the same idempotency key, and honours Retry-After when the server sends one. It never retries any other 4xx: a rejection is an answer, and asking again only spends your rate limit. Nor does it retry an answer that says a retry cannot help: a *_result_unknown or state_unconfirmed (the provider may have acted, and Payshen settles it from the provider's record), psp_error (the provider refused) or provider_unavailable. You get those at once.
const client = new Payshen("ps_live_...", {
timeoutMs: 15_000,
maxRetries: 3,
});Every call accepts an AbortSignal, so a request can be cancelled when the caller goes away.
Rotating a webhook secret
Pass an array while you cut over. There is always a window where some deliveries are signed with the old secret and some with the new, and without this a rotation drops events.
await verifyWebhookSignature({
secret: [process.env.PAYSHEN_WEBHOOK_SECRET_NEW!, process.env.PAYSHEN_WEBHOOK_SECRET_OLD!],
header: req.headers.get("x-payshen-signature"),
body: raw,
});Every candidate is compared in constant time, so a rotation never becomes a way to work out which secret is live. Once the old one stops appearing in your delivery log, drop it from the array.
When something goes wrong
Every response carries an X-Request-Id, and every error the client raises carries it as error.requestId. Quote it and we can find the exact call.
try {
await client.payments.create(input);
} catch (err) {
if (err instanceof PayshenError) {
log.error({ code: err.code, status: err.status, requestId: err.requestId });
}
throw err;
}Send your own X-Request-Id and it is echoed back, so one identifier spans your logs and ours.
Security
What the client does for you, and what stays yours.
- The key only travels in a header. Never in a URL, where it would end up in access logs, referrers and browser history.
- A plain-http base URL is refused. The header would be readable in transit.
http://localhostis allowed, for mock servers. - Signature comparison is timing-safe and the timestamp is checked, so a captured delivery goes stale. Inside the tolerance window it still verifies, so deduplicate on the event id. That, not the signature, is what makes a repeat harmless.
- The key is not shown when the client is logged. It is held in a private field, so
console.log,util.inspectandJSON.stringifyof a client leave it out. - Ids are checked before a request. A payment or suggestion id that is not 1 to 128 letters, digits,
_or-throwsPayshenValidationErrorand nothing is sent. - Retries cannot double-charge. Every POST carries an idempotency key, reused on every retry (see Idempotency).
- Yours: keep the key out of client-side code and out of version control, revoke it under Developers if it is ever exposed, and verify every webhook before acting on it. Revoking a key takes effect immediately.
Fixes are listed in the changelog, with the failure mode spelled out rather than summarised as "improved reliability".
Runtimes and bundling
Zero dependencies, and only fetch and WebCrypto, which are standard everywhere the package targets: Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge.
ESM and CommonJS with declarations, so import and require both work without a bundler plugin or a types shim. It is marked sideEffects: false, so a bundler can drop what you do not use.
import { Payshen } from "@payshen/sdk";Go live
The code does not change between sandbox and production. The key does, and a few things that sandbox forgives production does not.
- Create a live key. Under Developers, with the Live switch on. It starts with
ps_live_. Keep it in your secret store, never in the browser or the repository. - Register a production webhook endpoint. Sandbox endpoints never receive production events. Store the new signing secret next to the key.
- Deduplicate on event.id. With
claimWebhookEvent, or in the same transaction as the work when the event moves money. - Handle UNKNOWN and PENDING as in flight. Never as a decline, and never with a second capture or void.
- Name your idempotency keys after the order. So your own retries, not just the client's, cannot charge twice.
- Log the request id.
error.requestIdon every failure, so support can find the call.
Live processing
A live key reaches production data. Payments process through a provider connection once it is activated for your workspace, and activation is confirmed with you during onboarding.