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.
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. A 4xx is never retried.
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.
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);
}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.
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 });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.
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 5xx with exponential backoff and jitter, and honours Retry-After when the server sends one. It never retries a 4xx: a rejection is an answer, and asking again only spends your rate limit.
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.
- 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 in Settings 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. In Settings, for the production environment. 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.
