Skip to content

Developers Start

One endpoint takes a payment

Your backend calls Payshen. Payshen calls the providers you already have. The customer pays on a hosted page, never on yours. The whole contract is published as OpenAPI 3.1: import it into Postman or generate a client, no account needed.

Quickstart

Three steps to a sandbox payment you can follow end to end.

  1. Get a test key

    In the dashboard, open Settings and create an API key for the sandbox environment. It starts with ps_test_ and is shown once, so store it right away.

  2. Create a payment

    Send the amount in minor units, the currency, the customer's country and a return_url. The Idempotency-Key makes a retry safe.

    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",
        "return_url": "https://yourshop.com/checkout/return"
      }'

    Add capture_method: "manual" to authorize now and capture later.

  3. Read the response

    With a return_url the payment waits for the customer: send them to next_action.url. Without one, the payment is attempted at once and the response carries the outcome.

    Response
    {
      "data": {
        "payment_id": "0f2c...",
        "reference": "order-1024",
        "status": "REQUIRES_ACTION",
        "environment": "sandbox",
        "next_action": { "type": "REDIRECT", "url": "https://..." }
      }
    }

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.

To rotate a key, create a new one in Settings, deploy it, then revoke the old one. A workspace can hold several keys at once, so nothing fails in between, and a revoked key stops working immediately.

Environments

Sandbox is for building. It behaves like production in every way that touches your code, and in none that touches your money.

  • It never bills. Usage is counted on production payments only.
  • It never trains routing. Sandbox outcomes do not change where a live payment goes, and they stay out of live reporting.
  • The hosted page has buttons, not card fields. A sandbox next_action.url opens a clearly labelled Payshen page where you approve, approve after a 3DS challenge, decline with a reason, or cancel. No card details are collected and no money moves.
  • Keys, idempotency and webhooks are per environment. The same Idempotency-Key in sandbox and production are two different keys, and a sandbox webhook endpoint receives only sandbox events.

Live

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.

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 Idempotency-Key when you create a payment. A retry with the same key and body returns the original response with Idempotent-Replay: true, so a timeout never charges twice. The same key with a different body is refused with 422 idempotency_key_reuse rather than doing something you did not ask for.

Idempotency

Use a key that names the business operation, such as the order id. A fresh random key on every retry protects nothing.

Webhooks

Events arrive signed as t=<unix>,v1=HMAC_SHA256(secret, "<t>.<body>"). Verify against the raw body, before you parse it.

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);

A delivery that fails on the network or answers 5xx is sent again; a 4xx is taken as your answer. Every attempt is logged with its response code. GET /api/v1/events is the polling equivalent if you would rather not host an endpoint.

Duplicates are normal

Because deliveries are retried, the same event can arrive twice. Record each event's id and skip what you have already processed. claimWebhookEvent does it in one call.

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
422
validation_error, idempotency_key_reuse
429
rate_limited

Every code, with what it means: error codes.

Endpoints

TypeScript SDK

Install it with npm i @payshen/sdk. Typed responses, bounded retries with jitter, an idempotency key on every POST, and webhook verification. Zero dependencies, ESM and CommonJS, types included. It is typechecked against the contract on every build.

Full SDK reference

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.

API documentation - Payshen