Contents
Developers Start
One endpoint takes a payment
Your backend calls Payshen. Payshen picks one of the providers you already have and sends the payment there. The customer pays on a hosted page, or in the provider's own fields embedded in your checkout; card data never reaches you or us. The whole contract is published as OpenAPI 3.1: import it into Postman or generate a client, no account needed.
Quickstart
Five steps, about five minutes, to a sandbox payment you can follow end to end. Every example is in curl, TypeScript, Python, PHP, Java, C# and Go: pick yours once and the page follows it.
Get a test key
In the dashboard, open Developers with the Test switch on and press Generate key. It starts with
ps_test_and is shown once, so store it right away, on your server only.Create a payment
Send the amount in minor units, the currency, the customer's country and a
return_url. TheIdempotency-Keymakes a retry safe. Withcapture_method: "manual"the money is held and you capture it later, for example when you ship.curl -X POST https://www.payshen.com/api/v1/payments \ -H "Authorization: Bearer ps_test_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1024" \ -d '{ "amount": 5000, "currency": "EUR", "country": "DE", "reference": "order-1024", "capture_method": "manual", "return_url": "https://yourshop.com/checkout/return" }'Send the customer to pay
The answer says
REQUIRES_ACTION: redirect the customer tonext_action.url. They pay there and come back to yourreturn_url.Response{ "data": { "payment_id": "0f2c...", "reference": "order-1024", "status": "REQUIRES_ACTION", "environment": "sandbox", "next_action": { "type": "REDIRECT", "url": "https://..." } } }Hear the outcome
Do not trust the return to your page to mean paid: a customer can close the tab, and some providers confirm later. Add a webhook endpoint under Developers → Webhook endpoints, press Send test event to check your signature code, and act on the event Payshen sends when the payment is decided.
What arrives at your endpoint{ "id": "evt_6b1d...", "type": "payment.authorized", "created": "2026-10-10T14:09:34.753Z", "data": { "id": "0f2c...", "payment_id": "0f2c...", "reference": "order-1024", "payment_status": "AUTHORIZED", "amount": 5000, "currency": "EUR", "environment": "sandbox" } }Capture, and refund if you need to
Capture what you authorized, in full or in part. Refund later, in full or in part. Each call takes its own Idempotency-Key.
# Capture what you authorized (or part of it) curl -X POST https://www.payshen.com/api/v1/payments/0f2c.../capture \ -H "Authorization: Bearer ps_test_..." \ -H "Idempotency-Key: capture-order-1024" \ -H "Content-Type: application/json" -d '{}' # Refund part of it later. 409 refund_pending means accepted, not failed: # payment.refunded or payment.refund_failed follows. curl -X POST https://www.payshen.com/api/v1/payments/0f2c.../refunds \ -H "Authorization: Bearer ps_test_..." \ -H "Idempotency-Key: refund-order-1024-1" \ -H "Content-Type: application/json" -d '{ "amount": 2000 }'Some providers answer a capture or refund with "accepted" and confirm it a little later. Payshen then says so instead of guessing: see pending operations.
Authentication
Send the key as Authorization: Bearer <key>, from your server only. A ps_test_ key reads and writes sandbox data. A ps_live_ key reads and writes production data. Neither can see the other's payments, even with the id.
Rotating a key
Under Developers, open the key's menu and choose Roll. You get a new secret under the same name, shown once, and the old one keeps working until you revoke it. Deploy the new one, then revoke the old one from the same menu: a revoked key stops working at once.
A key can also be given an expiry when it is made (90, 180 or 365 days). After that date every request with it is answered 401 unauthorized, saying the key expired. The keys table shows each expiry and marks one due within two weeks; a rolled key gets the same period again.
Restricted keys and limits
A key is full access unless you restrict it when you make it. A restricted key carries only the scopes you pick: payments:read, payments:write, refunds:write, insights:read, suggestions:write, merchants:read and merchants:write. A call outside them is answered 403 insufficient_scope; a full-access key passes every check.
Each key may make 120 payment creates and 60 other calls a minute, and each address 300 calls a minute before the key is even read. Over a limit the answer is 429 rate_limited with Retry-After: 60. A request body may be at most 16 KB.
Environments
Test is for building. It behaves like production in every way that touches your code, and in none that touches your money. The switch at the top of the dashboard shows which one you are looking at.
- It never bills. Usage is counted on production payments only.
- It never trains live routing. Test outcomes teach Test mode's own routing, never where a live payment goes, and they stay out of live reporting.
- Keys, idempotency and webhooks are per environment. The same Idempotency-Key in test and production are two different keys, and a test webhook endpoint receives only test events.
Where a test customer pays
A test payment goes to one of your test connections. When that connection holds your provider's own sandbox credentials and live provider access is on for your workspace, the customer lands on the provider's real test page and pays with its test cards. Otherwise a clearly labelled Payshen simulator page offers buttons instead of card fields: approve, approve after a 3-D Secure challenge, decline with a reason, or cancel. Nothing is charged either way.
To try one connection on its own, use Send test payment in its menu under Providers: it goes through that connection only, whatever your routing rules say, and shows each step as it happens.
Live
A live key reaches production data. Payments go through a provider connection once it is activated for your workspace, and activation is confirmed with you during onboarding.
How a payment moves
A payment has one status, UPPERCASE on the wire, and it only moves forward. Most payments take one of these paths:
| From | To | When |
|---|---|---|
| REQUIRES_ACTION | AUTHORIZED | the customer paid; you asked for manual capture |
| REQUIRES_ACTION | CAPTURED | the customer paid; automatic capture |
| REQUIRES_ACTION | DECLINED or CANCELLED | refused by the issuer, or the customer left |
| AUTHORIZED | CAPTURED | you captured it |
| AUTHORIZED | CANCELLED | you voided it |
| CAPTURED | PARTIALLY_REFUNDED / REFUNDED | you refunded part or all of it |
| CAPTURED | CHARGEBACK | the customer's bank took the money back |
PENDING and UNKNOWN are not answers
Both mean the provider has it and we are waiting for its word. Never treat them as declined, never charge again, and never repeat a capture or void yourself: the first one may already have happened. The payment moves on by itself, and you get a webhook when it does.
Pending operations: captures, voids and refunds a provider only accepted
Some providers answer a capture, void or refund with "received" and confirm it a moment later. Payshen never records that as done before the provider says so:
- A capture or void answers
200with the payment stillPENDING. When the provider confirms, it becomesCAPTUREDorCANCELLEDand you getpayment.capturedorpayment.voided. - A refund answers
409 refund_pending: accepted, not refunded yet. Do not send it again. The same Idempotency-Key answers the final result once the provider confirms, and you getpayment.refundedorpayment.refund_failed. Until then its amount shows asrefund_pending_amounton the payment. - If a provider's confirmation never arrives, Payshen asks the provider where it can, and otherwise puts the payment in front of a person. A refund still waiting after a day is flagged on the payment.
Every status with its meaning: Statuses.
Routing and failover
You do not choose a provider per payment. Your routing rules, set in the dashboard, choose the connection: by the customer's country, the currency, the amount or the method, in the order you set. A connection is only considered if it can take the payment: the currency, the customer's country, the method and what your provider approved.
When the first provider says no
If the chosen connection fails for a technical reason it stated, or declines softly (a decline another provider might approve), the next eligible connection may be tried, up to three in one payment. Your routing mode decides how far that goes: fixed never tries another, failover only after a technical failure, priority and auto after a soft decline too.
A payment is never sent to a second provider when the first might have charged it (a timeout, an unclear answer), nor after a hard decline, a fraud block or anything only the customer can fix. That is how a retry never turns into a double charge. recovered: true on the payment's event says a second provider saved it.
When a customer comes back to pay an order a provider has just declined, the next payment goes to another provider first. Payshen recognises the order by its reference, amount and currency within 24 hours, so send the same reference for every try at one order. A decline only the customer can fix, such as no funds or an expired card, changes nothing, and fixed never reorders.
Debugging a routing_failed? Send debug: true on create and the answer lists every connection that was left out, and why.
Amounts
Every amount is an integer in the currency's minor unit, and the scale follows the currency. 5000 is EUR 50.00, JPY 5,000 and KWD 5.000.
Minor units
A wrong scale does not fail. The request succeeds, for the wrong amount. If you format amounts yourself, take the exponent from Intl rather than dividing by 100. How, with code.
Retries and idempotency
Send an Idempotency-Key with every POST. A retry with the same key and body returns the original response with Idempotent-Replay: true, so a timeout never charges twice.
Idempotency
Use a key that names the business operation, such as the order id. A fresh random key on every retry protects nothing.
More
The same key with a different body is refused with 422 idempotency_key_reuse rather than doing something you did not ask for. A key is required with a ps_live_ key and optional in test. While the first request with a key is still running, a second answers 409 idempotency_in_progress: wait and retry with the same key.
Once a provider may have received a request, a retry under its key is never sent to a provider again while the key is kept: 24 hours after its answer, and for as long as the answer is unknown. After that the same key is a new request.
Webhooks
Payshen POSTs an event to your endpoint when something happens to a payment or a dispute. Verify the signature against the raw body, answer any 2xx within 5 seconds, and skip events you have already handled. Endpoint URLs must be https, and addresses inside private networks are refused.
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);Duplicates are normal
Because deliveries are retried, the same event can arrive twice, and not always in order. Record each event's id and skip what you have already processed. claimWebhookEvent does it in one call.
Signature, retries and testing
The X-Payshen-Signature header is t=<unix>,v1=HMAC_SHA256(secret, "<t>.<body>"), with the endpoint's signing secret. Refuse a t more than 5 minutes old. X-Payshen-Event repeats the type. To change the secret, choose Roll secret on the endpoint's page; the SDK verifier accepts a list of secrets so you can deploy the new one first.
Anything but a 2xx is sent again with the same event id, 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h, 8 h, 12 h apart: ten attempts over about 28 hours. 400, 410, 422 are read as your final answer and not retried; a 401, 403 or 404 is retried, because it is usually a deploy in progress. After the last attempt you can resend the event from the endpoint's delivery history.
Send test event on an endpoint posts a webhook.test event, signed like a real one, so you can check reachability and your signature code without a payment. Prefer polling? GET /api/v1/events returns the same events.
Every event and the fields it carries: Webhook events.
Errors
Failures are { "error": { "code", "message" } }. Switch on code, which is stable. The message is for people and may change. The ones you will meet first:
- 401
- unauthorized
- 409
- idempotency_in_progress, routing_failed, refund_pending
- 422
- validation_error, idempotency_key_reuse
- 429
- rate_limited
Every code, with what it means and whether to retry: error codes.
Never put a card number, or a value that looks like one, in reference, description, metadata or any other field: the payment is refused with 422 pan_not_accepted, because Payshen never accepts card data.
Endpoints
Generated from the contract. Each one links to its fields, types and status codes in the reference.
- get/api/v1/eventsList events across all payments
- get/api/v1/insightsList insights
- get/api/v1/merchantsList your sub-merchants
- post/api/v1/merchantsProvision a sub-merchant
- get/api/v1/openapi.jsonThis document
- get/api/v1/paymentsList payments
- post/api/v1/paymentsCreate a payment
- get/api/v1/payments/{id}Retrieve a payment
- post/api/v1/payments/{id}/captureCapture an authorization
- post/api/v1/payments/{id}/completeComplete an embedded payment after customer authentication
- post/api/v1/payments/{id}/confirmConfirm an embedded payment
- get/api/v1/payments/{id}/eventsList a payment's timeline
- post/api/v1/payments/{id}/refundsRefund a captured or settled payment
- post/api/v1/payments/{id}/voidVoid an authorization
- get/api/v1/provider-webhooks/{connectionId}Inbound provider payment callback (GET)
- post/api/v1/provider-webhooks/{connectionId}Inbound provider payment callback
- post/api/v1/psp-webhooks/{pspId}Inbound provider webhook
- get/api/v1/suggestionsList suggestions
- get/api/v1/suggestions/{id}Retrieve a suggestion
- post/api/v1/suggestions/{id}/acceptAccept a suggestion
- post/api/v1/suggestions/{id}/rejectReject a suggestion
- post/api/v1/suggestions/{id}/snoozeSnooze a suggestion
SDKs
Each one: an idempotency key on every POST, bounded retries with jitter, typed errors with the API's own code, and webhook verification. No dependencies.
- TypeScript:
npm i @payshen/sdk. ESM and CommonJS, types included, typechecked against the contract on every build. TypeScript guide - PHP:
composer require payshen/payshen-php. PHP 8.2 and newer. PHP guide - Python:
pip install payshen. Python 3.9 and newer, the standard library only. Python guide - WooCommerce: a plugin, no code. Install it from WordPress.org, connect it to Payshen in one click, and take payments. WooCommerce guide
Java, C# or Go: the quickstart has every step and the webhook check in your language, over plain HTTP.
v1 is frozen: fields and error codes will not change meaning, and anything breaking would ship as v2 beside it. Every change is dated in the changelog.